You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/09/17 21:34:21 UTC
(hugegraph-doc) branch staging/oink-reviewed created (now c58521057)
This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a change to branch staging/oink-reviewed
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
at c58521057 feat(site): stage OINK A+B+C candidate
This branch includes the following new commits:
new c58521057 feat(site): stage OINK A+B+C candidate
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(hugegraph-doc) 01/01: feat(site): stage OINK A+B+C candidate
Posted by ji...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch staging/oink-reviewed
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit c585210573d04c5434fb4d23220e9b77c2f5a48d
Author: dark <ji...@apache.org>
AuthorDate: Fri Sep 18 05:33:43 2026 +0800
feat(site): stage OINK A+B+C candidate
- integrate reviewed OINK core, Community and ASF downloads\n- include fixed staging push workflow targeting asf-staging-oink\n- preserve five-version aggregate and click-gated AI contracts\n- publish only after local and independent review evidence
---
.github/workflows/hugo.yml | 437 ++++-
.gitignore | 4 +
README.md | 20 +-
assets/js/hugegraph-shell.js | 461 +++++
assets/js/kapa-adapter.js | 432 +++++
assets/scss/_styles_project.scss | 227 ++-
assets/scss/_variables_project.scss | 49 +
assets/scss/community-members.scss | 62 +
content/cn/docs/_index.md | 1 +
content/cn/docs/clients/_index.md | 3 +
content/cn/docs/clients/restful-api/_index.md | 2 +
content/cn/docs/clients/restful-api/vertex.md | 9 +-
content/cn/docs/config/config-authentication.md | 2 +
content/cn/docs/config/config-guide.md | 4 +-
content/cn/docs/download/download.md | 95 +-
content/cn/docs/introduction/_index.md | 2 +
.../quickstart/computing/hugegraph-computer.md | 2 +
.../docs/quickstart/hugegraph/hugegraph-hstore.md | 6 +-
.../cn/docs/quickstart/hugegraph/hugegraph-pd.md | 2 +
.../docs/quickstart/hugegraph/hugegraph-server.md | 17 +-
.../docs/quickstart/toolchain/hugegraph-hubble.md | 3 +
.../docs/quickstart/toolchain/hugegraph-loader.md | 2 +
content/en/docs/_index.md | 1 +
content/en/docs/clients/_index.md | 3 +
content/en/docs/clients/restful-api/_index.md | 2 +
content/en/docs/clients/restful-api/vertex.md | 9 +-
content/en/docs/config/config-authentication.md | 2 +
content/en/docs/config/config-guide.md | 4 +-
content/en/docs/download/download.md | 90 +-
content/en/docs/introduction/_index.md | 2 +
.../quickstart/computing/hugegraph-computer.md | 2 +
.../docs/quickstart/hugegraph/hugegraph-hstore.md | 6 +-
.../en/docs/quickstart/hugegraph/hugegraph-pd.md | 2 +
.../docs/quickstart/hugegraph/hugegraph-server.md | 17 +-
.../docs/quickstart/toolchain/hugegraph-hubble.md | 3 +
.../docs/quickstart/toolchain/hugegraph-loader.md | 2 +
contribution.md | 25 +-
data/community/github-map.json | 4 +
data/community/roster.json | 209 ++
data/downloads/asf.json | 53 +
data/landing/community/cn.yaml | 5 +
data/landing/community/en.yaml | 5 +
data/version_routes.json | 1513 +++++++++++++++
dist/validate-site-output.py | 692 ++++++-
hugo.yaml | 31 +-
i18n/en.yaml | 27 +
i18n/{cn.yaml => zh-CN.yaml} | 26 +
layouts/_partials/actions/manifest.html | 23 +
layouts/_partials/ai/config.html | 33 +
layouts/_partials/asf-downloads.html | 101 +
layouts/_partials/backlinks-sources.html | 21 +
layouts/_partials/backlinks.html | 50 +
layouts/_partials/community/members.html | 37 +
layouts/_partials/community/members.md | 23 +
layouts/_partials/content/image-zoom-config.html | 9 +
layouts/_partials/content/render.html | 5 +
layouts/_partials/hooks/body-end.html | 59 +
layouts/_partials/hooks/head-end.html | 5 +
.../landing/sections/community-members.html | 1 +
layouts/_partials/navbar-item.html | 16 +-
layouts/_partials/navbar.html | 33 +-
layouts/_partials/print/page-content.html | 8 +
layouts/_partials/share/bar.html | 13 +
layouts/_partials/shell/sidebar-panel.html | 10 +-
layouts/_partials/version-link.html | 15 +
layouts/_partials/version-menu-links.html | 17 +
layouts/_partials/version-options.html | 22 +
layouts/_partials/version-target.html | 68 +
layouts/_shortcodes/asf-downloads.html | 8 +
layouts/community/landing.md | 29 +
layouts/landing.html | 6 +
layouts/landing.print.html | 6 +
scripts/community_roster.md | 41 +
scripts/community_roster.py | 710 +++++++
scripts/fixtures/community_search_queries.json | 26 +
scripts/hugo.sh | 111 ++
scripts/test_community_roster.py | 785 ++++++++
scripts/test_download_data.py | 198 ++
scripts/test_hugo_wrapper.py | 204 ++
scripts/test_validate_site_output.py | 635 +++++++
scripts/test_versioning.py | 1535 ++++++++++++++-
scripts/versioning.py | 2008 ++++++++++++++++++--
.../bootstrap-controls/accordion-active-dark.svg | 3 +
static/img/bootstrap-controls/accordion-active.svg | 3 +
static/img/bootstrap-controls/accordion-dark.svg | 3 +
static/img/bootstrap-controls/accordion.svg | 3 +
static/img/bootstrap-controls/carousel-next.svg | 3 +
static/img/bootstrap-controls/carousel-prev.svg | 3 +
static/img/bootstrap-controls/check-checked.svg | 3 +
.../img/bootstrap-controls/check-indeterminate.svg | 3 +
static/img/bootstrap-controls/close.svg | 3 +
static/img/bootstrap-controls/navbar-dark.svg | 3 +
static/img/bootstrap-controls/navbar-light.svg | 3 +
static/img/bootstrap-controls/radio-checked.svg | 3 +
static/img/bootstrap-controls/select-dark.svg | 3 +
static/img/bootstrap-controls/select.svg | 3 +
static/img/bootstrap-controls/switch-checked.svg | 3 +
static/img/bootstrap-controls/switch-dark.svg | 3 +
static/img/bootstrap-controls/switch-focus.svg | 3 +
static/img/bootstrap-controls/switch.svg | 3 +
.../img/bootstrap-controls/validation-invalid.svg | 7 +
static/img/bootstrap-controls/validation-valid.svg | 3 +
static/img/social/hugegraph-default.png | Bin 0 -> 984595 bytes
tests/e2e/accessibility.spec.js | 45 +
tests/e2e/ai-enabled.yaml | 8 +
tests/e2e/ai.spec.js | 163 ++
tests/e2e/artifact-test.js | 31 +
tests/e2e/package-lock.json | 103 +
tests/e2e/package.json | 16 +
tests/e2e/platform.spec.js | 222 +++
tests/e2e/playwright.config.js | 44 +
tests/e2e/search-ranking.spec.js | 103 +
tests/e2e/versioning.spec.js | 305 +++
tests/e2e/visual.spec.js | 43 +
tests/e2e/workflow-contract.test.cjs | 99 +
tests/ui-ai/ai-enabled.yaml | 8 +
tests/ui-ai/ai-invalid.yaml | 8 +
tests/ui-ai/backlinks-render.test.cjs | 54 +
tests/ui-ai/fixtures/backlinks.yaml | 5 +
.../fixtures/backlinks/content/en/docs/_index.md | 3 +
.../fixtures/backlinks/content/en/docs/source-1.md | 5 +
.../fixtures/backlinks/content/en/docs/source-2.md | 5 +
.../fixtures/backlinks/content/en/docs/source-3.md | 5 +
.../fixtures/backlinks/content/en/docs/source-4.md | 5 +
.../fixtures/backlinks/content/en/docs/source-5.md | 5 +
.../fixtures/backlinks/content/en/docs/source-6.md | 5 +
.../fixtures/backlinks/content/en/docs/target.md | 6 +
tests/ui-ai/kapa-adapter.test.cjs | 287 +++
tests/ui-ai/ui-contract.test.cjs | 126 ++
tests/ui-ai/version-manifest.test.cjs | 158 ++
tests/ui-ai/version-switch.test.cjs | 194 ++
versions.json | 16 +
132 files changed, 12994 insertions(+), 595 deletions(-)
diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml
index ecc3c4664..e93fa797f 100644
--- a/.github/workflows/hugo.yml
+++ b/.github/workflows/hugo.yml
@@ -3,231 +3,462 @@ name: Build and deploy site
on:
pull_request:
push:
- branches: [master]
+ branches: [master, staging/oink-reviewed]
+ workflow_dispatch:
+ inputs:
+ operation:
+ description: Fixed deployment operation
+ required: true
+ type: choice
+ options: [staging-next, production-history-refresh]
+ scope:
+ description: Versions to build
+ required: true
+ default: latest
+ type: choice
+ options: [latest, full]
+ candidate_branch:
+ description: ASF repository branch used for latest
+ required: true
+ type: string
+ confirmation:
+ description: Fixed target confirmation from the runbook
+ required: true
+ type: string
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
+ group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || ((inputs.operation == 'staging-next' || github.ref == 'refs/heads/staging/oink-reviewed') && 'asf-staging-oink' || 'asf-site') }}
cancel-in-progress: true
env:
HUGO_VERSION: 0.165.0
HUGO_ENVIRONMENT: production
- HUGO_CACHEDIR: /tmp/hugo_cache
+ PRODUCTION_ORIGIN: https://hugegraph.apache.org/
+ STAGING_ORIGIN: https://hugegraph-oink.staged.apache.org/
jobs:
prepare:
runs-on: ubuntu-latest
- permissions:
- contents: read
+ timeout-minutes: 10
+ permissions: { contents: read }
+ env:
+ HUGO_CACHEDIR: /tmp/hugo-cache-${{ github.run_id }}-${{ github.run_attempt }}-prepare
outputs:
versions: ${{ steps.matrix.outputs.versions }}
+ selection: ${{ steps.plan.outputs.selection }}
+ site_origin: ${{ steps.plan.outputs.site_origin }}
+ historical_origin: ${{ steps.plan.outputs.historical_origin }}
+ artifact_prefix: ${{ steps.plan.outputs.artifact_prefix }}
+ publish_branch: ${{ steps.plan.outputs.publish_branch }}
+ latest_sha: ${{ steps.plan.outputs.latest_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
-
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: Setup Hugo Extended
+ uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
+ with:
+ hugo-version: ${{ env.HUGO_VERSION }}
+ extended: true
+ - name: Install WebP validators
+ run: |
+ sudo apt-get update
+ sudo apt-get install --yes --no-install-recommends webp
+ command -v cwebp
+ command -v dwebp
+
+ - name: Resolve trusted event plan
+ id: plan
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ EVENT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ OPERATION: ${{ inputs.operation }}
+ SCOPE: ${{ inputs.scope }}
+ CANDIDATE_BRANCH: ${{ inputs.candidate_branch }}
+ CONFIRMATION: ${{ inputs.confirmation }}
+ run: |
+ set -euo pipefail
+ all_selection="$(jq -er '[.versions[].id] | join(",")' versions.json)"
+ latest_selection="$(jq -er '[.versions[] | select(.archived == false) | .id] | join(",")' versions.json)"
+ latest_ref="$(jq -er '[.versions[] | select(.archived == false) | .ref] | join(",")' versions.json)"
+ test -n "$all_selection"
+ test -n "$latest_selection"
+ test "${latest_selection#*,}" = "$latest_selection"
+ test -n "$latest_ref"
+ test "${latest_ref#*,}" = "$latest_ref"
+ selection="$all_selection"
+ site_origin="$PRODUCTION_ORIGIN"
+ historical_origin="$PRODUCTION_ORIGIN"
+ artifact_prefix="production"
+ publish_branch=""
+ latest_sha="$EVENT_SHA"
+
+ if [[ "$EVENT_NAME" == workflow_dispatch ]]; then
+ test "$GITHUB_REF" = refs/heads/master
+ candidate="$CANDIDATE_BRANCH"
+ test -n "$candidate"
+ [[ "$candidate" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]
+ [[ "$candidate" != refs/* && "$candidate" != */ && "$candidate" != *//* ]]
+ [[ "$candidate" != *..* ]]
+ [[ ! "$candidate" =~ ^[0-9a-fA-F]{40}$ ]]
+ [[ "$candidate" != asf-site && "$candidate" != asf-staging-oink ]]
+ resolved="$(git ls-remote --heads origin "refs/heads/$candidate")"
+ test "$(wc -l <<<"$resolved" | tr -d ' ')" = 1
+ latest_sha="$(awk '{print $1}' <<<"$resolved")"
+ [[ "$latest_sha" =~ ^[0-9a-f]{40}$ ]]
+ git fetch --no-tags origin "$latest_sha"
+
+ case "$OPERATION" in
+ staging-next)
+ test "$CONFIRMATION" = "publish asf-staging-oink"
+ site_origin="$STAGING_ORIGIN"
+ artifact_prefix="staging"
+ publish_branch="asf-staging-oink"
+ if [[ "$SCOPE" == latest ]]; then
+ selection="$latest_selection"
+ else
+ test "$SCOPE" = full
+ historical_origin="$STAGING_ORIGIN"
+ fi
+ ;;
+ production-history-refresh)
+ test "$CONFIRMATION" = "publish asf-site"
+ test "$candidate" = "$latest_ref"
+ test "$SCOPE" = full
+ publish_branch="asf-site"
+ ;;
+ *) exit 1 ;;
+ esac
+ elif [[ "$EVENT_NAME" == push ]]; then
+ case "$GITHUB_REF" in
+ refs/heads/staging/oink-reviewed)
+ # Reviewed candidate branch: staging can precede master merges.
+ test "$GITHUB_REPOSITORY" = apache/hugegraph-doc
+ site_origin="$STAGING_ORIGIN"
+ historical_origin="$STAGING_ORIGIN"
+ artifact_prefix="staging"
+ publish_branch="asf-staging-oink"
+ ;;
+ refs/heads/master)
+ publish_branch="asf-site"
+ ;;
+ *) exit 1 ;;
+ esac
+ else
+ test "$EVENT_NAME" = pull_request
+ fi
+
+ {
+ echo "selection=$selection"
+ echo "site_origin=$site_origin"
+ echo "historical_origin=$historical_origin"
+ echo "artifact_prefix=$artifact_prefix"
+ echo "publish_branch=$publish_branch"
+ echo "latest_sha=$latest_sha"
+ } >> "$GITHUB_OUTPUT"
- name: Validate source and version tooling
run: |
bash dist/validate-links.sh
- python3 -m unittest discover -s scripts -p 'test_*.py' -v
-
+ PYTHONDONTWRITEBYTECODE=1 \
+ python3 -m unittest discover -s scripts -p 'test_*.py' -v
- name: Resolve immutable version matrix
id: matrix
env:
- LATEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ LATEST_SHA: ${{ steps.plan.outputs.latest_sha }}
+ SELECTION: ${{ steps.plan.outputs.selection }}
run: |
python3 scripts/versioning.py prepare \
- --latest-sha "$LATEST_SHA" \
+ --latest-sha "$LATEST_SHA" --select "$SELECTION" \
--output resolved-versions.json
echo "versions=$(jq -c '.include' resolved-versions.json)" >> "$GITHUB_OUTPUT"
-
- name: Upload resolved version manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: resolved-versions-${{ github.sha }}
+ name: resolved-versions-${{ github.run_id }}-${{ github.run_attempt }}
path: resolved-versions.json
+ retention-days: 7
if-no-files-found: error
build:
needs: prepare
runs-on: ubuntu-latest
- permissions:
- contents: read
+ timeout-minutes: 20
+ permissions: { contents: read }
+ env:
+ HUGO_CACHEDIR: /tmp/hugo-cache-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.version.id }}
strategy:
fail-fast: false
+ max-parallel: 5
matrix:
version: ${{ fromJSON(needs.prepare.outputs.versions) }}
- site:
- - name: production
- origin: https://hugegraph.apache.org/
- - name: staging
- origin: https://hugegraph-oink.staged.apache.org/
- name: Build ${{ matrix.site.name }} / ${{ matrix.version.id }}
+ name: Build ${{ matrix.version.id }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
-
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
-
+ - name: Fetch immutable resolved source
+ env:
+ RESOLVED_SHA: ${{ matrix.version.sha }}
+ run: |
+ [[ "$RESOLVED_SHA" =~ ^[0-9a-f]{40}$ ]]
+ git fetch --no-tags origin "$RESOLVED_SHA"
+ git cat-file -e "$RESOLVED_SHA^{commit}"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
-
- name: Setup Hugo Extended
uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
-
- name: Cache Hugo resources
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ env.HUGO_CACHEDIR }}
key: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}-${{ hashFiles('go.sum') }}
- restore-keys: |
- ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}-
-
+ restore-keys: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}-
- name: Verify pinned OINK module
run: |
go mod verify
- go list -m all
test "$(go list -m -f '{{ .Path }}')" = "github.com/apache/hugegraph-doc"
test "$(go list -m all | wc -l)" -eq 2
test "$(go list -m -f '{{ .Path }}@{{ .Version }}' github.com/pgsty/oink)" = "github.com/pgsty/oink@v1.0.0"
test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}@{{ .Version }}{{ end }}' github.com/pgsty/oink)"
-
- name: Build isolated version artifact
env:
OINK_PYTHON: python3
+ SITE_ORIGIN: ${{ needs.prepare.outputs.site_origin }}
+ HISTORICAL_ORIGIN: ${{ needs.prepare.outputs.historical_origin }}
run: |
python3 scripts/versioning.py build \
- --version "${{ matrix.version.id }}" \
- --sha "${{ matrix.version.sha }}" \
- --site-origin "${{ matrix.site.origin }}" \
+ --version "${{ matrix.version.id }}" --sha "${{ matrix.version.sha }}" \
+ --site-origin "$SITE_ORIGIN" --historical-origin "$HISTORICAL_ORIGIN" \
--output "${RUNNER_TEMP}/version-public"
python3 scripts/versioning.py validate \
- --version "${{ matrix.version.id }}" \
- --sha "${{ matrix.version.sha }}" \
- --site-origin "${{ matrix.site.origin }}" \
+ --version "${{ matrix.version.id }}" --sha "${{ matrix.version.sha }}" \
+ --site-origin "$SITE_ORIGIN" --historical-origin "$HISTORICAL_ORIGIN" \
--artifact "${RUNNER_TEMP}/version-public"
-
- name: Upload isolated version artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: ${{ matrix.site.name }}-${{ matrix.version.id }}
+ name: ${{ needs.prepare.outputs.artifact_prefix }}-${{ matrix.version.id }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/version-public
include-hidden-files: true
+ retention-days: 1
if-no-files-found: error
- # Keep this job id aligned with the required `deploy` status in .asf.yaml.
- # It produces deployable artifacts but does not publish untrusted PR code.
- deploy:
- if: always()
+ aggregate:
needs: [prepare, build]
runs-on: ubuntu-latest
- permissions:
- contents: read
+ timeout-minutes: 15
+ permissions: { contents: read }
steps:
- - name: Require every version build to succeed
- env:
- PREPARE_RESULT: ${{ needs.prepare.result }}
- BUILD_RESULT: ${{ needs.build.result }}
- run: |
- test "$PREPARE_RESULT" = success
- test "$BUILD_RESULT" = success
-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- fetch-depth: 0
persist-credentials: false
-
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
-
- - name: Download resolved version manifest
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
- name: resolved-versions-${{ github.sha }}
+ name: resolved-versions-${{ github.run_id }}-${{ github.run_attempt }}
path: resolved
-
- - name: Download production versions
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- pattern: production-*
- path: version-artifacts
-
- - name: Download staging versions
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
- pattern: staging-*
+ pattern: ${{ needs.prepare.outputs.artifact_prefix }}-*-${{ github.run_id }}-${{ github.run_attempt }}
path: version-artifacts
-
- - name: Aggregate production and staging sites
+ - name: Assemble publishable site
+ env:
+ PREFIX: ${{ needs.prepare.outputs.artifact_prefix }}
+ SITE_ORIGIN: ${{ needs.prepare.outputs.site_origin }}
+ HISTORICAL_ORIGIN: ${{ needs.prepare.outputs.historical_origin }}
+ SELECTION: ${{ needs.prepare.outputs.selection }}
run: |
+ extra=()
+ if [[ "$PREFIX" == staging ]]; then
+ extra+=(--asf-profile oink --asf-whoami asf-staging-oink)
+ fi
python3 scripts/versioning.py aggregate \
- --artifacts version-artifacts \
- --artifact-prefix production- \
+ --artifacts version-artifacts --artifact-prefix "$PREFIX-" \
+ --artifact-suffix="-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
--resolved-manifest resolved/resolved-versions.json \
- --site-origin https://hugegraph.apache.org/ \
- --output "${RUNNER_TEMP}/public-production"
- python3 scripts/versioning.py aggregate \
- --artifacts version-artifacts \
- --artifact-prefix staging- \
- --resolved-manifest resolved/resolved-versions.json \
- --site-origin https://hugegraph-oink.staged.apache.org/ \
- --asf-profile oink \
- --asf-whoami asf-staging-oink \
- --output "${RUNNER_TEMP}/public-staging"
-
- - name: Upload production aggregate
+ --site-origin "$SITE_ORIGIN" --historical-origin "$HISTORICAL_ORIGIN" \
+ --select "$SELECTION" \
+ --output "${RUNNER_TEMP}/public-site" "${extra[@]}"
+ - name: Upload publishable aggregate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: hugegraph-site-production-${{ github.sha }}
- path: ${{ runner.temp }}/public-production
+ name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/public-site
include-hidden-files: true
+ retention-days: ${{ github.event_name == 'pull_request' && 1 || 7 }}
if-no-files-found: error
- - name: Upload ASF staging aggregate
+ e2e:
+ needs: [prepare, aggregate]
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions: { contents: read }
+ env:
+ HUGO_CACHEDIR: /tmp/hugo-cache-${{ github.run_id }}-${{ github.run_attempt }}-e2e
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: tests/e2e/package-lock.json
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: Setup Hugo Extended
+ uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
+ with:
+ hugo-version: ${{ env.HUGO_VERSION }}
+ extended: true
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: public-site
+ - name: Install Chromium test workspace
+ working-directory: tests/e2e
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+ - name: Build manifest-derived AI fixture
+ run: |
+ python3 scripts/versioning.py config \
+ --version latest \
+ --site-origin http://127.0.0.1:4174/ \
+ --historical-origin "$PRODUCTION_ORIGIN" \
+ --output "${RUNNER_TEMP}/ai-version-config.json"
+ hugo \
+ --config "hugo.yaml,${RUNNER_TEMP}/ai-version-config.json,tests/e2e/ai-enabled.yaml" \
+ --destination "${RUNNER_TEMP}/ai-site" \
+ --cleanDestinationDir --gc --minify --environment production
+ - name: Run blocking Chromium contracts
+ working-directory: tests/e2e
+ env:
+ SITE_ROOT: ${{ github.workspace }}/public-site
+ AI_SITE_ROOT: ${{ runner.temp }}/ai-site
+ EXPECTED_VERSIONS: ${{ needs.prepare.outputs.selection }}
+ run: npm run test:ci
+ - name: Upload E2E report
+ if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: hugegraph-site-staging-${{ github.sha }}
- path: ${{ runner.temp }}/public-staging
- include-hidden-files: true
- if-no-files-found: error
+ name: playwright-report-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ tests/e2e/playwright-report
+ tests/e2e/test-results
+ retention-days: 7
+ if-no-files-found: warn
- publish:
- if: github.event_name == 'push' && github.ref == 'refs/heads/master'
- needs: deploy
+ visual:
+ needs: [prepare, aggregate]
runs-on: ubuntu-latest
- permissions:
- contents: write
+ timeout-minutes: 15
+ continue-on-error: true
+ permissions: { contents: read }
steps:
- - name: Download reviewed production aggregate
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
- name: hugegraph-site-production-${{ github.sha }}
- path: public-production
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: tests/e2e/package-lock.json
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: public-site
+ - name: Capture advisory visual states
+ working-directory: tests/e2e
+ env:
+ SITE_ROOT: ${{ github.workspace }}/public-site
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+ npm run test:visual
+ - name: Upload advisory visual evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: visual-evidence-${{ github.run_id }}-${{ github.run_attempt }}
+ path: tests/e2e/visual-results
+ retention-days: 7
+ if-no-files-found: warn
+
+ # Required check name in .asf.yaml. It gates artifacts without write access.
+ deploy:
+ if: always()
+ needs: [prepare, build, aggregate, e2e]
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions: { contents: read }
+ steps:
+ - name: Require all blocking jobs to succeed
+ env:
+ PREPARE_RESULT: ${{ needs.prepare.result }}
+ BUILD_RESULT: ${{ needs.build.result }}
+ AGGREGATE_RESULT: ${{ needs.aggregate.result }}
+ E2E_RESULT: ${{ needs.e2e.result }}
+ run: |
+ test "$PREPARE_RESULT" = success
+ test "$BUILD_RESULT" = success
+ test "$AGGREGATE_RESULT" = success
+ test "$E2E_RESULT" = success
- - name: Publish clean aggregate to asf-site
+ publish:
+ if: needs.prepare.outputs.publish_branch != ''
+ needs: [prepare, deploy]
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions: { contents: write }
+ steps:
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: public-site
+ - name: Verify fixed publication target
+ env:
+ TARGET: ${{ needs.prepare.outputs.publish_branch }}
+ PREFIX: ${{ needs.prepare.outputs.artifact_prefix }}
+ run: |
+ case "$PREFIX:$TARGET" in
+ production:asf-site|staging:asf-staging-oink) ;;
+ *) exit 1 ;;
+ esac
+ test -f public-site/build-metadata/versions.json
+ - name: Publish clean aggregate
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./public-production
- publish_branch: asf-site
+ publish_dir: ./public-site
+ publish_branch: ${{ needs.prepare.outputs.publish_branch }}
keep_files: false
force_orphan: false
- commit_message: ${{ github.event.head_commit.message }}
+ commit_message: Deploy ${{ needs.prepare.outputs.artifact_prefix }} site from run ${{ github.run_id }} attempt ${{ github.run_attempt }}
diff --git a/.gitignore b/.gitignore
index 2e76301fc..cc2d3a994 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,10 @@ public-staging/
resources/
node_modules/
package-lock.json
+!/tests/e2e/package-lock.json
+/tests/e2e/playwright-report/
+/tests/e2e/test-results/
+/tests/e2e/visual-results/
.hugo_build.lock
nohup.out
*.log
diff --git a/README.md b/README.md
index 0d2cda900..6b2b71db2 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ cd hugegraph-doc
hugo mod graph
# 3. Start the development server (auto-reload)
-hugo server
+scripts/hugo.sh server
```
Open http://localhost:1313 to preview.
@@ -51,7 +51,7 @@ hugegraph-doc/
│ └── en/ # 🇺🇸 English documentation (mirrors cn/ structure)
│
├── data/ # 🧭 Landing-page and footer data
-├── i18n/cn.yaml # 🌐 OINK interface strings for the /cn/ locale
+├── i18n/zh-CN.yaml # 🌐 OINK interface strings for the /cn/ locale
├── assets/ # 🖼️ Project brand assets
├── layouts/ # 📐 Hugo template overrides
├── static/ # 📁 Static files
@@ -84,9 +84,9 @@ See [contribution.md](./contribution.md) for the pinned toolchain, strict build,
| Command | Description |
|---------|-------------|
-| `hugo server` | Start dev server (hot reload) |
-| `hugo --cleanDestinationDir --gc --minify --environment production --printPathWarnings --printI18nWarnings --panicOnWarning` | Strict production build to `./public/` |
-| `hugo server -p 8080` | Custom port |
+| `scripts/hugo.sh server` | Start the manifest-aware dev server (hot reload) |
+| `scripts/hugo.sh build` | Strict, production-equivalent build to `./public/` |
+| `scripts/hugo.sh server -p 8080` | Start the dev server on a custom port |
---
@@ -111,7 +111,7 @@ cd hugegraph-doc
hugo mod graph
# 3. 启动开发服务器(支持热重载)
-hugo server
+scripts/hugo.sh server
```
打开 http://localhost:1313 预览网站。
@@ -135,7 +135,7 @@ hugegraph-doc/
│ └── en/ # 🇺🇸 英文文档(与 cn/ 结构一致)
│
├── data/ # 🧭 首页与页尾数据
-├── i18n/cn.yaml # 🌐 /cn/ 语言的 OINK 界面文案
+├── i18n/zh-CN.yaml # 🌐 /cn/ 语言的 OINK 界面文案
├── assets/ # 🖼️ 项目品牌资源
├── layouts/ # 📐 Hugo 模板覆盖
├── static/ # 📁 静态文件
@@ -168,9 +168,9 @@ hugegraph-doc/
| 命令 | 说明 |
|------|------|
-| `hugo server` | 启动开发服务器(热重载) |
-| `hugo --cleanDestinationDir --gc --minify --environment production --printPathWarnings --printI18nWarnings --panicOnWarning` | 严格构建生产版本到 `./public/` |
-| `hugo server -p 8080` | 指定端口 |
+| `scripts/hugo.sh server` | 启动读取版本清单的开发服务器(支持热重载) |
+| `scripts/hugo.sh build` | 严格构建与生产等价的站点到 `./public/` |
+| `scripts/hugo.sh server -p 8080` | 在指定端口启动开发服务器 |
---
diff --git a/assets/js/hugegraph-shell.js b/assets/js/hugegraph-shell.js
new file mode 100644
index 000000000..1f496169c
--- /dev/null
+++ b/assets/js/hugegraph-shell.js
@@ -0,0 +1,461 @@
+/**
+ * HugeGraph additions around OINK's shell.
+ *
+ * This file deliberately does not replace OINK's command palette. It only
+ * persists authored tree disclosures, makes a collapsed/dismissed sidebar
+ * inert, and adds an explicit retry control to the existing search error.
+ */
+(function (global) {
+ 'use strict';
+
+ var versionExecutorRegistries = new WeakSet();
+
+ function readConfig(documentObject) {
+ var node = documentObject.getElementById('hg-shell-config');
+ if (!node) return { version: 'latest', locale: 'en' };
+ try {
+ return JSON.parse(node.textContent || '{}');
+ } catch (_) {
+ return { version: 'latest', locale: 'en' };
+ }
+ }
+
+ function safeStorage(windowObject) {
+ try {
+ var storage = windowObject.localStorage;
+ var probe = '__hg_sidebar_probe__';
+ storage.setItem(probe, '1');
+ storage.removeItem(probe);
+ return storage;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function setTreeExpanded(button, expanded, documentObject) {
+ var target = documentObject.getElementById(
+ button.getAttribute('aria-controls'),
+ );
+ if (!target) return;
+ button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+ target.classList.toggle('td-is-open', expanded);
+ var label = expanded
+ ? button.dataset.tdLabelCollapse
+ : button.dataset.tdLabelExpand;
+ if (label) button.setAttribute('aria-label', label);
+ }
+
+ function initTreePersistence(windowObject, documentObject, config) {
+ var buttons = Array.prototype.slice.call(
+ documentObject.querySelectorAll('[data-td-shell-tree-toggle][aria-controls]'),
+ );
+ if (!buttons.length) return;
+ var storage = safeStorage(windowObject);
+ var key =
+ 'oink.sidebar.v1.' +
+ String(config.version || 'latest') +
+ '.' +
+ String(config.locale || 'en');
+ var valid = new Set(
+ buttons.map(function (button) {
+ return button.getAttribute('aria-controls');
+ }),
+ );
+ var saved = [];
+ if (storage) {
+ try {
+ var parsed = JSON.parse(storage.getItem(key) || '[]');
+ if (Array.isArray(parsed)) {
+ saved = parsed.filter(function (id) {
+ return typeof id === 'string' && valid.has(id);
+ });
+ }
+ } catch (_) {
+ saved = [];
+ }
+ }
+ var remembered = new Set(saved);
+
+ buttons.forEach(function (button) {
+ var item = button.closest('li');
+ var activePath = item && item.classList.contains('td-active-path');
+ setTreeExpanded(
+ button,
+ Boolean(activePath || remembered.has(button.getAttribute('aria-controls'))),
+ documentObject,
+ );
+ button.addEventListener('click', function () {
+ global.setTimeout(function () {
+ if (!storage) return;
+ var expanded = buttons
+ .filter(function (candidate) {
+ var candidateItem = candidate.closest('li');
+ return (
+ candidate.getAttribute('aria-expanded') === 'true' &&
+ !(candidateItem &&
+ candidateItem.classList.contains('td-active-path'))
+ );
+ })
+ .map(function (candidate) {
+ return candidate.getAttribute('aria-controls');
+ });
+ try {
+ storage.setItem(key, JSON.stringify(expanded));
+ } catch (_) {
+ /* Active-path expansion remains the storage-free fallback. */
+ }
+ }, 0);
+ });
+ });
+
+ // Rewriting the filtered set removes stale node IDs after navigation
+ // changes without retaining a second schema/version marker.
+ if (storage) {
+ try {
+ storage.setItem(key, JSON.stringify(saved));
+ } catch (_) {
+ /* Ignore storage becoming unavailable after the probe. */
+ }
+ }
+ }
+
+ function initSidebarIsolation(windowObject, documentObject) {
+ var html = documentObject.documentElement;
+ var sidebar = documentObject.getElementById('td-shell-sidebar');
+ if (!sidebar) return;
+ var restore = documentObject.querySelector('.hg-sidebar-restore');
+ var desktop = windowObject.matchMedia('(min-width: 768px)');
+
+ var panel = sidebar.querySelector('.td-shell-sidebar__panel');
+ // The native 16px panel edge cannot receive pointers while the sidebar is
+ // inert. Keep an equivalent pointer-only strip outside the inert subtree;
+ // the labelled navbar button remains the keyboard/touch equivalent.
+ var edge = documentObject.createElement('div');
+ edge.className = 'hg-sidebar-edge d-print-none';
+ edge.setAttribute('aria-hidden', 'true');
+ documentObject.body.appendChild(edge);
+ var closeTimer;
+ var pointerLockUntil = 0;
+ function dynamic() {
+ return desktop.matches &&
+ html.getAttribute('data-td-shell-sidebar') === 'collapsed';
+ }
+ function preview() {
+ if (!dynamic()) return;
+ windowObject.clearTimeout(closeTimer);
+ sidebar.classList.add('td-shell-sidebar--overlay');
+ sync();
+ }
+ function closePreview() {
+ windowObject.clearTimeout(closeTimer);
+ closeTimer = windowObject.setTimeout(function () {
+ if (!sidebar.contains(documentObject.activeElement)) {
+ sidebar.classList.remove('td-shell-sidebar--overlay');
+ sync();
+ }
+ }, 350);
+ }
+ edge.addEventListener('pointerenter', function (event) {
+ if (event.pointerType !== 'touch' && Date.now() >= pointerLockUntil) preview();
+ });
+ edge.addEventListener('pointerleave', closePreview);
+ if (restore) {
+ restore.addEventListener('pointerenter', function (event) {
+ if (event.pointerType !== 'touch' && Date.now() >= pointerLockUntil) preview();
+ });
+ restore.addEventListener('pointerleave', closePreview);
+ restore.addEventListener('keydown', function (event) {
+ if (event.key !== 'ArrowRight' || !dynamic()) return;
+ event.preventDefault();
+ preview();
+ var first = sidebar.querySelector('a[href], button');
+ if (first) first.focus();
+ });
+ }
+ if (panel) {
+ panel.addEventListener('pointerenter', function () {
+ windowObject.clearTimeout(closeTimer);
+ });
+ panel.addEventListener('focusout', closePreview);
+ panel.addEventListener('keydown', function (event) {
+ if (event.key !== 'Escape' || !dynamic()) return;
+ event.preventDefault();
+ if (restore) restore.focus();
+ sidebar.classList.remove('td-shell-sidebar--overlay');
+ sync();
+ });
+ }
+
+ function sync() {
+ var collapsed =
+ html.getAttribute('data-td-shell-sidebar') === 'collapsed';
+ var drawerOpen =
+ html.getAttribute('data-td-shell-drawer') === 'open';
+ // OINK owns the persistent collapsed mode and pointer overlay. Keep
+ // focus inside an open preview safe when its native pointerleave fires.
+ if (desktop.matches && collapsed &&
+ !sidebar.classList.contains('td-shell-sidebar--overlay') &&
+ sidebar.contains(documentObject.activeElement)) {
+ sidebar.classList.add('td-shell-sidebar--overlay');
+ }
+ var overlay = sidebar.classList.contains('td-shell-sidebar--overlay');
+ var isolated = desktop.matches ? collapsed && !overlay : !drawerOpen;
+ edge.hidden = !desktop.matches || !collapsed;
+ if (restore) restore.setAttribute('aria-expanded', String(!isolated));
+ if (restore) restore.hidden = !desktop.matches || !collapsed;
+ sidebar.inert = isolated;
+ if (isolated) sidebar.setAttribute('aria-hidden', 'true');
+ else sidebar.removeAttribute('aria-hidden');
+ if (
+ isolated &&
+ sidebar.contains(documentObject.activeElement) &&
+ restore &&
+ restore.offsetParent !== null
+ ) {
+ restore.focus();
+ }
+ }
+
+ new MutationObserver(sync).observe(html, {
+ attributes: true,
+ attributeFilter: ['data-td-shell-sidebar', 'data-td-shell-drawer'],
+ });
+ new MutationObserver(sync).observe(sidebar, { attributes: true, attributeFilter: ['class'] });
+ desktop.addEventListener('change', sync);
+ documentObject
+ .querySelectorAll('[data-td-shell-sidebar-toggle], [data-td-shell-drawer-close]')
+ .forEach(function (button) {
+ button.addEventListener('click', function () {
+ var hadFocus = documentObject.activeElement === button;
+ // Match OINK's cooldown so a newly visible trigger under the pointer
+ // does not immediately undo an explicit keyboard collapse.
+ pointerLockUntil = Date.now() + 150;
+ // Run after OINK's native click listener regardless of chunk order.
+ global.setTimeout(function () {
+ if (dynamic() && sidebar.contains(button) && restore) {
+ restore.hidden = false;
+ restore.focus();
+ sidebar.classList.remove('td-shell-sidebar--overlay');
+ }
+ sync();
+ // Both external restore controls disappear when pinned. Transfer
+ // their focus after making the sidebar operable; hover and clicks
+ // that did not focus a trigger must not steal unrelated focus.
+ if (hadFocus && desktop.matches && !dynamic() &&
+ !sidebar.contains(button) && button.offsetParent === null) {
+ var collapse = sidebar.querySelector('.td-shell-sidebar__collapse');
+ if (collapse) collapse.focus();
+ }
+ }, 0);
+ }, true);
+ });
+ sync();
+ }
+
+ function initScrollableTables(documentObject) {
+ documentObject.querySelectorAll('[data-td-asset-table-scroll]').forEach(function (region) {
+ if (region.dataset.hgTableScrollBound !== undefined) return;
+ region.dataset.hgTableScrollBound = '';
+ region.addEventListener('keydown', function (event) {
+ if (region.scrollWidth <= region.clientWidth) return;
+ var delta = 0;
+ if (event.key === 'ArrowRight') delta = 80;
+ else if (event.key === 'ArrowLeft') delta = -80;
+ else if (event.key === 'Home') delta = -region.scrollLeft;
+ else if (event.key === 'End') delta = region.scrollWidth - region.clientWidth - region.scrollLeft;
+ else return;
+ event.preventDefault();
+ region.scrollBy({ left: delta, behavior: 'smooth' });
+ });
+ });
+ }
+
+ function initSearchRetry(windowObject, documentObject) {
+ var root = documentObject.getElementById('td-shell-search');
+ if (!root) return;
+ var list = root.querySelector('.td-shell-search__list');
+ var input = root.querySelector('.td-shell-search__input');
+ var status = root.querySelector('[data-td-shell-search-status]');
+ if (!list || !input || !status) return;
+ var scheduled = false;
+
+ function sync() {
+ scheduled = false;
+ var existing = list.querySelector('[data-hg-search-retry]');
+ var failure = root.dataset.tdTIndexUnavailable || '';
+ var failed =
+ failure &&
+ (status.textContent.trim() === failure ||
+ Array.prototype.some.call(
+ list.querySelectorAll('.td-shell-search__empty'),
+ function (node) {
+ return node.textContent.trim() === failure;
+ },
+ ));
+ if (failed && existing) return existing;
+ if (existing) existing.remove();
+ if (!failed) return null;
+
+ var notice = documentObject.createElement('div');
+ notice.className = 'hg-search-retry';
+ notice.dataset.hgSearchRetry = '';
+ var text = documentObject.createElement('span');
+ text.textContent = failure;
+ var button = documentObject.createElement('button');
+ button.type = 'button';
+ button.className = 'btn btn-sm btn-outline-primary';
+ button.textContent =
+ documentObject.documentElement.lang === 'cn' ||
+ documentObject.documentElement.lang.indexOf('zh') === 0
+ ? '重试'
+ : 'Retry';
+ button.addEventListener('click', function () {
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.focus();
+ });
+ notice.appendChild(text);
+ notice.appendChild(button);
+ list.appendChild(notice);
+ return notice;
+ }
+
+ function schedule() {
+ if (scheduled) return;
+ scheduled = true;
+ global.requestAnimationFrame(sync);
+ }
+ new MutationObserver(schedule).observe(list, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ });
+ new MutationObserver(schedule).observe(status, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ });
+ schedule();
+ }
+
+ function versionTarget(option, locationObject) {
+ if (!option || !option.url) return '';
+ try {
+ var target = new URL(option.url, locationObject.href);
+ if (target.protocol !== 'http:' && target.protocol !== 'https:')
+ return '';
+ if (option.equivalent === true && option.fallback !== true) {
+ target.search = locationObject.search || '';
+ target.hash = locationObject.hash || '';
+ }
+ return target.href;
+ } catch (_) {
+ return '';
+ }
+ }
+
+ function boolData(value) {
+ return value === true || value === 'true';
+ }
+
+ function initVersionSwitching(windowObject, documentObject) {
+ Array.prototype.forEach.call(
+ documentObject.querySelectorAll('a[data-hg-version-id]'),
+ function (anchor) {
+ var target = versionTarget(
+ {
+ url: anchor.getAttribute('href'),
+ equivalent: boolData(anchor.dataset.hgVersionEquivalent),
+ fallback: boolData(anchor.dataset.hgVersionFallback),
+ },
+ windowObject.location,
+ );
+ if (target) anchor.setAttribute('href', target);
+ },
+ );
+
+ var registry = windowObject.OinkActions;
+ if (
+ !registry ||
+ typeof registry.registerExecutor !== 'function' ||
+ versionExecutorRegistries.has(registry)
+ ) {
+ return;
+ }
+ registry.registerExecutor('switch_version', function (context) {
+ var target = versionTarget(
+ context && context.value,
+ windowObject.location,
+ );
+ if (target) windowObject.location.assign(target);
+ return { action: 'switch_version', url: target };
+ });
+ versionExecutorRegistries.add(registry);
+ }
+
+ function consumeVersionFallback(windowObject, documentObject, config) {
+ var locationObject = windowObject.location;
+ if (locationObject.hash !== '#hg-version-fallback' || !config.docsRoot)
+ return null;
+ var docsPath;
+ try {
+ docsPath = new URL(config.docsRoot, locationObject.href).pathname;
+ } catch (_) {
+ return null;
+ }
+ if (locationObject.pathname !== docsPath) return null;
+
+ windowObject.history.replaceState(
+ windowObject.history.state,
+ '',
+ locationObject.pathname + locationObject.search,
+ );
+ var notice = documentObject.createElement('div');
+ ['alert', 'alert-info', 'hg-version-fallback', 'd-print-none'].forEach(
+ function (name) {
+ notice.classList.add(name);
+ },
+ );
+ notice.dataset.hgVersionFallbackNotice = '';
+ notice.setAttribute('role', 'status');
+ notice.setAttribute('aria-live', 'polite');
+ notice.setAttribute('aria-atomic', 'true');
+ notice.textContent = config.versionFallbackMessage || '';
+ var container =
+ (documentObject.querySelector && documentObject.querySelector('main')) ||
+ documentObject.body;
+ if (container) container.prepend(notice);
+ return notice;
+ }
+
+ function init(windowObject, documentObject) {
+ var config = readConfig(documentObject);
+ consumeVersionFallback(windowObject, documentObject, config);
+ initVersionSwitching(windowObject, documentObject);
+ initTreePersistence(windowObject, documentObject, config);
+ initSidebarIsolation(windowObject, documentObject);
+ initScrollableTables(documentObject);
+ initSearchRetry(windowObject, documentObject);
+ }
+
+ var api = {
+ init: init,
+ readConfig: readConfig,
+ safeStorage: safeStorage,
+ setTreeExpanded: setTreeExpanded,
+ versionTarget: versionTarget,
+ initVersionSwitching: initVersionSwitching,
+ consumeVersionFallback: consumeVersionFallback,
+ };
+ global.HugeGraphShell = api;
+ if (typeof module === 'object' && module.exports) module.exports = api;
+
+ if (global.document) {
+ if (global.document.readyState === 'loading') {
+ global.document.addEventListener('DOMContentLoaded', function () {
+ init(global, global.document);
+ });
+ } else {
+ init(global, global.document);
+ }
+ }
+})(typeof window === 'object' ? window : globalThis);
diff --git a/assets/js/kapa-adapter.js b/assets/js/kapa-adapter.js
new file mode 100644
index 000000000..2c1035296
--- /dev/null
+++ b/assets/js/kapa-adapter.js
@@ -0,0 +1,432 @@
+/**
+ * Click-gated Kapa adapter for OINK.
+ *
+ * The third-party bundle URL and privacy posture are fixed here. The page
+ * supplies only reviewed public identifiers and localized labels.
+ */
+(function (global) {
+ 'use strict';
+
+ var BUNDLE_URL = 'https://widget.kapa.ai/kapa-widget.bundle.js';
+ var TIMEOUT_MS = 5000;
+
+ function trimmedQuery(value) {
+ return String(value || '').trim();
+ }
+
+ function mixWithWhite(color, percentage) {
+ var match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color);
+ if (!match) throw new Error('Kapa theme color must be a six-digit hexadecimal color');
+ var weight = percentage / 100;
+ return '#' + match.slice(1).map(function (channel) {
+ var mixed = Math.round(parseInt(channel, 16) * (1 - weight) + 255 * weight);
+ return mixed.toString(16).padStart(2, '0');
+ }).join('');
+ }
+
+ function readConfig(documentObject) {
+ var node = documentObject.getElementById('hg-ai-config');
+ if (!node) return null;
+ try {
+ var config = JSON.parse(node.textContent || '{}');
+ return config.websiteId && config.sourceGroupId ? config : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function invokeKapa(windowObject, method, value) {
+ var api = windowObject.Kapa;
+ if (typeof api === 'function') return api(method, value);
+ if (api && typeof api[method] === 'function') return api[method](value);
+ throw new Error('Kapa API is unavailable');
+ }
+
+ function preinitialize(windowObject, force) {
+ if (!force && windowObject.Kapa) return windowObject.Kapa;
+ if (
+ force &&
+ windowObject.Kapa &&
+ windowObject.Kapa.hgKapaPreinitialized &&
+ Array.isArray(windowObject.Kapa.q)
+ ) {
+ windowObject.Kapa.q.length = 0;
+ }
+ var queue = function () {
+ queue.c(arguments);
+ };
+ queue.q = [];
+ queue.hgKapaPreinitialized = true;
+ queue.c = function (args) {
+ queue.q.push(args);
+ };
+ windowObject.Kapa = queue;
+ return queue;
+ }
+
+ function scriptAttributes(config) {
+ return {
+ 'data-website-id': config.websiteId,
+ 'data-source-group-ids-include': config.sourceGroupId,
+ 'data-language': config.locale,
+ 'data-project-name': 'Apache HugeGraph',
+ 'data-project-color': config.themeColor,
+ 'data-project-color-dark': mixWithWhite(config.themeColor, 48),
+ 'data-surface-color': '#ffffff',
+ 'data-surface-elevated-color': '#f6f4fb',
+ 'data-surface-hover-color': '#eeeafd',
+ 'data-text-color': '#24212d',
+ 'data-text-muted-color': '#686275',
+ 'data-border-color': '#d9d4e4',
+ 'data-anchor-color': config.themeColor,
+ 'data-surface-color-dark': '#17151d',
+ 'data-surface-elevated-color-dark': '#221f2b',
+ 'data-surface-hover-color-dark': '#302b3d',
+ 'data-text-color-dark': '#f0edf7',
+ 'data-text-muted-color-dark': '#b6afc2',
+ 'data-border-color-dark': '#494254',
+ 'data-anchor-color-dark': mixWithWhite(config.themeColor, 60),
+ 'data-color-scheme-selector': "[data-bs-theme='dark']",
+ 'data-font-family':
+ '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif',
+ 'data-modal-content-border-radius': '12px',
+ 'data-modal-content-border': '1px solid #d9d4e4',
+ 'data-modal-content-border-dark': '1px solid #494254',
+ 'data-launcher-button-hidden': 'true',
+ 'data-render-on-load': 'false',
+ 'data-search-mode-enabled': 'false',
+ 'data-modal-open-on-command-k': 'false',
+ 'data-consent-required': 'false',
+ 'data-user-analytics-cookie-enabled': 'false',
+ 'data-user-analytics-fingerprint-enabled': 'false',
+ 'data-exit-feedback-enabled': 'false',
+ 'data-user-satisfaction-feedback-enabled': 'false',
+ 'data-bot-protection-mechanism': 'hcaptcha',
+ };
+ }
+
+ function createController(windowObject, documentObject, config) {
+ var state = 'idle';
+ var consented = false;
+ var pending = null;
+ var consent = documentObject.querySelector('[data-hg-ai-consent]');
+ var attempt = 0;
+ var timer = 0;
+ var lastTrigger = null;
+ var activeScript = null;
+ var activeQueue = null;
+ var status = documentObject.querySelector('[data-hg-ai-status]');
+
+ function renderState(next, message) {
+ state = next;
+ documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(function (button) {
+ button.dataset.hgAiState = next;
+ button.disabled = next === 'loading';
+ if (next === 'loading') button.setAttribute('aria-busy', 'true');
+ else button.removeAttribute('aria-busy');
+ if (message) button.setAttribute('title', message);
+ else button.removeAttribute('title');
+ });
+ if (status) {
+ status.textContent = message || '';
+ status.classList.toggle('visually-hidden', !message);
+ }
+ }
+
+ function openWidget(query, submit) {
+ invokeKapa(windowObject, 'setSourceGroupIDs', [config.sourceGroupId]);
+ invokeKapa(windowObject, 'open', {
+ mode: 'ai',
+ query: query,
+ submit: submit,
+ });
+ }
+
+ function discardAttempt(serial) {
+ if (
+ activeScript &&
+ activeScript.dataset.hgKapaAttempt === String(serial)
+ ) {
+ activeScript.remove();
+ activeScript = null;
+ }
+ if (
+ activeQueue &&
+ activeQueue.hgKapaPreinitialized &&
+ Array.isArray(activeQueue.q)
+ ) {
+ activeQueue.q.length = 0;
+ if (windowObject.Kapa === activeQueue) {
+ try {
+ delete windowObject.Kapa;
+ } catch (_) {
+ windowObject.Kapa = undefined;
+ }
+ }
+ }
+ activeQueue = null;
+ }
+
+ function fail(serial) {
+ if (serial !== attempt || state !== 'loading') return;
+ windowObject.clearTimeout(timer);
+ discardAttempt(serial);
+ renderState('error', config.labels.error);
+ }
+
+ function ready(serial, query, submit) {
+ if (serial !== attempt || state !== 'loading') return;
+ windowObject.clearTimeout(timer);
+ renderState('ready', '');
+ openWidget(query, submit);
+ }
+
+ function ensureScript(serial, query, submit, retrying) {
+ var loaded = false;
+ var rendered = false;
+ activeQueue = preinitialize(windowObject, retrying);
+ if (retrying) {
+ invokeKapa(windowObject, 'onModalClose', restoreFocus);
+ }
+ var script = documentObject.createElement('script');
+ activeScript = script;
+ script.async = true;
+ script.src =
+ BUNDLE_URL + (retrying ? '?hg-retry=' + encodeURIComponent(serial) : '');
+ script.dataset.hgKapaWidget = '';
+ script.dataset.hgKapaAttempt = String(serial);
+ var attrs = scriptAttributes(config);
+ Object.keys(attrs).forEach(function (name) {
+ script.setAttribute(name, attrs[name]);
+ });
+ function finish() {
+ if (loaded && rendered) ready(serial, query, submit);
+ }
+ script.addEventListener('load', function () {
+ loaded = true;
+ finish();
+ }, { once: true });
+ script.addEventListener('error', function () {
+ fail(serial);
+ }, { once: true });
+ try {
+ invokeKapa(windowObject, 'render', {
+ onRender: function () {
+ rendered = true;
+ finish();
+ },
+ });
+ } catch (_) {
+ fail(serial);
+ return;
+ }
+ documentObject.head.appendChild(script);
+ }
+
+ function load(query, submit) {
+ query = trimmedQuery(query);
+ if (state === 'loading') return;
+ if (state === 'ready') {
+ openWidget(query, Boolean(submit && query));
+ return;
+ }
+ var retrying = state === 'error';
+ var serial = ++attempt;
+ renderState('loading', '');
+ timer = windowObject.setTimeout(function () {
+ fail(serial);
+ }, TIMEOUT_MS);
+ ensureScript(serial, query, Boolean(submit && query), retrying);
+ }
+
+ function cancelConsent() {
+ pending = null;
+ renderState('idle', '');
+ if (consent && consent.open) consent.close();
+ restoreFocus();
+ }
+
+ function activate(query, submit, trigger) {
+ if (state === 'loading' || state === 'consent') return;
+ lastTrigger = trigger || documentObject.activeElement;
+ if (consented) {
+ load(query, submit);
+ return;
+ }
+ // Fail closed if the local consent panel is unavailable.
+ if (!consent || typeof consent.showModal !== 'function') {
+ renderState('error', config.labels.error);
+ return;
+ }
+ pending = { query: trimmedQuery(query), submit: submit };
+ renderState('consent', '');
+ consent.showModal();
+ }
+
+ if (consent) {
+ // Keep the underlying search palette from consuming modal keyboard events.
+ consent.addEventListener('keydown', function (event) {
+ event.stopPropagation();
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ cancelConsent();
+ }
+ });
+ consent.querySelector('[data-hg-ai-continue]').addEventListener('click', function () {
+ if (!pending) return;
+ var request = pending;
+ pending = null;
+ consented = true;
+ consent.close();
+ load(request.query, request.submit);
+ });
+ consent.querySelector('[data-hg-ai-cancel]').addEventListener('click', cancelConsent);
+ consent.addEventListener('cancel', function (event) {
+ event.preventDefault();
+ cancelConsent();
+ });
+ }
+
+ function restoreFocus() {
+ if (lastTrigger && typeof lastTrigger.focus === 'function') {
+ lastTrigger.focus();
+ }
+ }
+
+ activeQueue = preinitialize(windowObject);
+ invokeKapa(windowObject, 'onModalClose', restoreFocus);
+
+ return {
+ activate: activate,
+ getState: function () { return state; },
+ };
+ }
+
+ function init(windowObject, documentObject) {
+ var config = readConfig(documentObject);
+ if (!config) return null;
+ var controller = createController(windowObject, documentObject, config);
+ var root = documentObject.getElementById('td-shell-search');
+ var input = root && root.querySelector('.td-shell-search__input');
+ var list = root && root.querySelector('.td-shell-search__list');
+ var syncing = false;
+
+ function bind(button) {
+ if (button.dataset.hgAiBound !== undefined) return;
+ button.dataset.hgAiBound = '';
+ button.addEventListener('click', function () {
+ controller.activate(
+ button.dataset.hgAiQuery || '',
+ button.dataset.hgAiSubmit === 'true',
+ button,
+ );
+ });
+ }
+ documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(bind);
+
+ function syncTail() {
+ syncing = false;
+ if (!root || !input || !list || root.hidden) return;
+ var old = list.querySelector('[data-hg-ai-search-tail]');
+ var query = trimmedQuery(input.value);
+ if (!query || query.charAt(0) === '>') {
+ if (old) old.remove();
+ return;
+ }
+ var choiceLabel = root.dataset.tdTChoice || '';
+ if (
+ choiceLabel &&
+ Array.prototype.some.call(
+ list.querySelectorAll('.td-shell-search__group-label'),
+ function (label) { return label.textContent.trim() === choiceLabel; },
+ )
+ ) {
+ if (old) old.remove();
+ return;
+ }
+ var loading = root.dataset.tdTLoading || '';
+ if (
+ loading &&
+ Array.prototype.some.call(
+ list.querySelectorAll('.td-shell-search__empty'),
+ function (node) { return node.textContent.trim() === loading; },
+ )
+ ) {
+ if (old) old.remove();
+ return;
+ }
+ var oldButton = old && old.querySelector('[data-hg-ask-ai]');
+ if (oldButton && oldButton.dataset.hgAiQuery === query) return;
+ if (old) old.remove();
+
+ var group = documentObject.createElement('div');
+ group.className = 'td-shell-search__group hg-ai-search-tail';
+ group.dataset.hgAiSearchTail = '';
+ group.setAttribute('role', 'group');
+ var label = documentObject.createElement('div');
+ label.className = 'td-shell-search__group-label';
+ label.textContent = config.labels.ask;
+ var row = documentObject.createElement('button');
+ row.type = 'button';
+ row.className = 'td-shell-search__item hg-ai-search-tail__button';
+ row.dataset.hgAskAi = '';
+ row.dataset.hgAiQuery = query;
+ row.dataset.hgAiSubmit = 'true';
+ var icon = documentObject.createElement('i');
+ icon.className =
+ 'fa-solid fa-wand-magic-sparkles td-shell-search__item-icon';
+ icon.setAttribute('aria-hidden', 'true');
+ var meta = documentObject.createElement('span');
+ meta.className = 'td-shell-search__item-meta';
+ var title = documentObject.createElement('span');
+ title.className = 'td-shell-search__item-title';
+ title.textContent = config.labels.ask + ': “' + query + '”';
+ var detail = documentObject.createElement('span');
+ detail.className = 'td-shell-search__item-ref';
+ detail.textContent = config.historical ? config.labels.latest + '.' : '';
+ meta.appendChild(title);
+ meta.appendChild(detail);
+ row.appendChild(icon);
+ row.appendChild(meta);
+ group.appendChild(label);
+ group.appendChild(row);
+ list.appendChild(group);
+ bind(row);
+ }
+
+ if (list) {
+ new MutationObserver(function () {
+ if (syncing) return;
+ syncing = true;
+ windowObject.requestAnimationFrame(syncTail);
+ }).observe(list, { childList: true, subtree: true });
+ input.addEventListener('input', syncTail);
+ syncTail();
+ }
+ return controller;
+ }
+
+ var api = {
+ BUNDLE_URL: BUNDLE_URL,
+ TIMEOUT_MS: TIMEOUT_MS,
+ createController: createController,
+ init: init,
+ invokeKapa: invokeKapa,
+ preinitialize: preinitialize,
+ readConfig: readConfig,
+ scriptAttributes: scriptAttributes,
+ trimmedQuery: trimmedQuery,
+ };
+ global.HugeGraphKapa = api;
+ if (typeof module === 'object' && module.exports) module.exports = api;
+
+ if (global.document) {
+ if (global.document.readyState === 'loading') {
+ global.document.addEventListener('DOMContentLoaded', function () {
+ init(global, global.document);
+ });
+ } else {
+ init(global, global.document);
+ }
+ }
+})(typeof window === 'object' ? window : globalThis);
diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss
index e0941c5d5..2cf610cfd 100644
--- a/assets/scss/_styles_project.scss
+++ b/assets/scss/_styles_project.scss
@@ -1,6 +1,28 @@
// HugeGraph homepage: preserve the original product copy and isometric brand
// artwork while using OINK's accessible navigation and landing primitives.
-$hg-navbar-purple: #532fc9;
+// `params.ui.theme_color` publishes the project accent as this CSS custom
+// property in hooks/head-end.html.
+
+:root {
+ --hg-theme-color-soft: color-mix(
+ in srgb,
+ var(--hg-theme-color) 12%,
+ var(--bs-body-bg)
+ );
+ --hg-theme-color-hover: color-mix(
+ in srgb,
+ var(--hg-theme-color) 18%,
+ var(--bs-body-bg)
+ );
+}
+
+:where(a, button, input, summary):focus-visible {
+ outline-color: var(--hg-theme-color);
+}
+
+::selection {
+ background: var(--hg-theme-color-soft);
+}
.td-home {
// Keep OINK's display face for headings, but restore the original site's
@@ -39,7 +61,7 @@ $hg-navbar-purple: #532fc9;
}
&.td-scrolled {
- background: rgba($hg-navbar-purple, 0.96);
+ background: color-mix(in srgb, var(--hg-theme-color) 96%, transparent);
box-shadow: 0 6px 22px rgba(20, 17, 73, 0.24);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
@@ -248,7 +270,7 @@ $hg-navbar-purple: #532fc9;
// OINK's sticky header, search, version, language, theme, and help controls.
.td-shell-chrome .td-site-header {
border-block-end-color: rgba(255, 255, 255, 0.22);
- background: $hg-navbar-purple;
+ background: var(--hg-theme-color);
color: #fff;
-webkit-backdrop-filter: none;
backdrop-filter: none;
@@ -310,9 +332,9 @@ $hg-navbar-purple: #532fc9;
margin: calc(-1 * #{$spacer}) calc(-1 * #{$spacer}) 0;
padding-inline: $spacer;
border-block-end: 1px solid rgba(255, 255, 255, 0.22);
- background: $hg-navbar-purple;
+ background: var(--hg-theme-color);
color: #fff;
- box-shadow: 1px 0 $hg-navbar-purple;
+ box-shadow: 1px 0 var(--hg-theme-color);
.td-shell-wordmark {
background: none;
@@ -465,7 +487,140 @@ $hg-navbar-purple: #532fc9;
display: none;
}
+.td-nav-util.hg-sidebar-restore {
+ display: none;
+}
+
+.hg-sidebar-edge {
+ position: fixed;
+ inset-inline-start: 0;
+ top: 64px;
+ bottom: 0;
+ width: 16px;
+ z-index: 121;
+ border-inline-start: 3px solid var(--bs-border-color);
+
+ &:hover { border-inline-start-color: var(--bs-primary); }
+}
+
+@media (min-width: 768px) {
+ [data-td-shell-sidebar='collapsed'] {
+ .td-nav-util.hg-sidebar-restore:not([hidden]) {
+ display: inline-flex;
+ }
+ }
+}
+
+.hg-version-overflow {
+ color: var(--bs-body-color);
+ summary {
+ cursor: pointer;
+ padding: .5rem .75rem;
+ list-style: none;
+ &::-webkit-details-marker { display: none; }
+ &:focus-visible { outline: 2px solid var(--bs-primary); }
+ }
+}
+
+.hg-search-retry {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin: 0.5rem 0.75rem 0.75rem;
+ padding: 0.7rem 0.8rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.6rem;
+ background: var(--bs-tertiary-bg);
+ color: var(--bs-secondary-color);
+ font-size: 0.84rem;
+}
+
+.hg-ai-search-tail {
+ border-block-start: 1px solid var(--bs-border-color);
+
+ &__button {
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: start;
+
+ &:hover,
+ &:focus-visible {
+ background: var(--hg-theme-color-soft);
+ color: inherit;
+ }
+ }
+}
+
+.hg-ask-ai-launcher {
+ position: fixed;
+ z-index: 1040;
+ inset-inline-end: unquote('max(1rem, env(safe-area-inset-right))');
+ inset-block-end: unquote('max(1rem, env(safe-area-inset-bottom))');
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ min-height: 44px;
+ padding: 0.65rem 0.9rem;
+ border: 1px solid color-mix(in srgb, var(--hg-theme-color) 72%, #fff);
+ border-radius: 999px;
+ background: var(--hg-theme-color);
+ box-shadow: 0 8px 24px rgba(20, 17, 73, 0.24);
+ color: #fff;
+ font: 600 0.86rem/1 var(--td-ui-font-family);
+
+ &:hover,
+ &:focus-visible {
+ background: color-mix(in srgb, var(--hg-theme-color) 86%, #000);
+ color: #fff;
+ }
+
+ &[data-hg-ai-state='error'] {
+ border-style: dashed;
+ }
+
+ &[aria-busy='true'] {
+ cursor: wait;
+ opacity: 0.72;
+ }
+}
+
+.hg-ai-status:not(:empty) {
+ position: fixed;
+ z-index: 1039;
+ inset-inline-end: unquote('max(1rem, env(safe-area-inset-right))');
+ inset-block-end: unquote(
+ 'calc(max(1rem, env(safe-area-inset-bottom)) + 3.4rem)'
+ );
+ width: unquote('min(18rem, calc(100vw - 2rem))');
+ padding: 0.55rem 0.7rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.55rem;
+ background: var(--bs-body-bg);
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
+ color: var(--bs-body-color);
+ font-size: 0.78rem;
+}
+
@media (max-width: 767.98px) {
+ .td-shell-chrome .td-site-nav__menu-toggle {
+ border: 1px solid rgba(255, 255, 255, 0.42);
+ background: rgba(20, 17, 73, 0.22);
+ color: #fff;
+ }
+
+ .hg-ask-ai-launcher {
+ min-width: 44px;
+ min-height: 44px;
+ padding: 0.65rem;
+
+ span {
+ @include visually-hidden;
+ }
+ }
+
.hg-shell-mobile-utils,
.hg-landing-mobile-utils {
display: grid;
@@ -499,7 +654,8 @@ $hg-navbar-purple: #532fc9;
gap: 0.35rem;
> a,
- > button {
+ > button,
+ > .hg-version-overflow > a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
@@ -514,12 +670,18 @@ $hg-navbar-purple: #532fc9;
}
> a[aria-current='page'],
+ > .hg-version-overflow > a[aria-current='page'],
> button[aria-pressed='true'] {
background: var(--bs-primary-bg-subtle);
color: var(--bs-primary-text-emphasis);
}
}
+ .hg-shell-mobile-utils__links > .hg-version-overflow > a {
+ margin-inline-end: 0.35rem;
+ margin-block-start: 0.35rem;
+ }
+
.hg-shell-mobile-utils__github {
display: inline-flex;
align-items: center;
@@ -688,3 +850,56 @@ $hg-navbar-purple: #532fc9;
}
}
}
+
+// ASF download tables (layouts/_partials/asf-downloads.html): keep the cells
+// on one line so the wide table scrolls inside td-asset-list__table-wrap
+// instead of squeezing every column, and lay the release meta out inline.
+.hg-asf-release {
+ // The focusable wrapper owns horizontal scrolling, not td-content's table.
+ .td-asset-list__table-wrap {
+ overflow-x: auto !important;
+ overflow-y: hidden;
+ }
+
+ .td-asset-list__table {
+ display: table !important;
+ width: max-content;
+ min-width: 100%;
+ overflow: visible !important;
+ }
+
+ .td-asset-list__table th,
+ .td-asset-list__table td {
+ white-space: nowrap;
+ }
+}
+
+.hg-asf-release__meta {
+ list-style: none;
+ padding-left: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 1.5rem;
+}
+
+
+// Local disclosure renders before any third-party AI resource is requested.
+.hg-ai-consent {
+ width: calc(100% - 2rem);
+ max-width: 28rem;
+ padding: 1.5rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: .75rem;
+ background: var(--bs-body-bg);
+ color: var(--bs-body-color);
+ box-shadow: 0 .75rem 3rem rgba(0, 0, 0, .2);
+ &::backdrop { background: rgba(0, 0, 0, .4); }
+ h2 { font-size: 1.25rem; }
+ .hg-ai-consent-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: .75rem;
+ margin-top: 1.25rem;
+ }
+}
diff --git a/assets/scss/_variables_project.scss b/assets/scss/_variables_project.scss
new file mode 100644
index 000000000..42543db59
--- /dev/null
+++ b/assets/scss/_variables_project.scss
@@ -0,0 +1,49 @@
+// Keep Bootstrap's built-in controls compatible with the site's no-data-URL
+// policy. The stylesheet is emitted under /scss/, while static assets are
+// published under /img/ for every version artifact.
+$form-check-input-checked-bg-image:
+ url("../img/bootstrap-controls/check-checked.svg");
+$form-check-radio-checked-bg-image:
+ url("../img/bootstrap-controls/radio-checked.svg");
+$form-check-input-indeterminate-bg-image:
+ url("../img/bootstrap-controls/check-indeterminate.svg");
+
+$form-switch-bg-image:
+ url("../img/bootstrap-controls/switch.svg");
+$form-switch-focus-bg-image:
+ url("../img/bootstrap-controls/switch-focus.svg");
+$form-switch-checked-bg-image:
+ url("../img/bootstrap-controls/switch-checked.svg");
+
+$form-select-indicator:
+ url("../img/bootstrap-controls/select.svg");
+$form-feedback-icon-valid:
+ url("../img/bootstrap-controls/validation-valid.svg");
+$form-feedback-icon-invalid:
+ url("../img/bootstrap-controls/validation-invalid.svg");
+
+$navbar-light-toggler-icon-bg:
+ url("../img/bootstrap-controls/navbar-light.svg");
+$navbar-dark-toggler-icon-bg:
+ url("../img/bootstrap-controls/navbar-dark.svg");
+
+$accordion-button-icon:
+ url("../img/bootstrap-controls/accordion.svg");
+$accordion-button-active-icon:
+ url("../img/bootstrap-controls/accordion-active.svg");
+
+$carousel-control-prev-icon-bg:
+ url("../img/bootstrap-controls/carousel-prev.svg");
+$carousel-control-next-icon-bg:
+ url("../img/bootstrap-controls/carousel-next.svg");
+$btn-close-bg:
+ url("../img/bootstrap-controls/close.svg");
+
+$form-select-indicator-dark:
+ url("../img/bootstrap-controls/select-dark.svg");
+$form-switch-bg-image-dark:
+ url("../img/bootstrap-controls/switch-dark.svg");
+$accordion-button-icon-dark:
+ url("../img/bootstrap-controls/accordion-dark.svg");
+$accordion-button-active-icon-dark:
+ url("../img/bootstrap-controls/accordion-active-dark.svg");
diff --git a/assets/scss/community-members.scss b/assets/scss/community-members.scss
new file mode 100644
index 000000000..f5cfa240c
--- /dev/null
+++ b/assets/scss/community-members.scss
@@ -0,0 +1,62 @@
+.hg-community-members {
+ --hg-community-columns: 5;
+ &__role + &__role { margin-top: 2.5rem; }
+ &__grid {
+ display: grid;
+ grid-template-columns: repeat(var(--hg-community-columns), minmax(0, 1fr));
+ gap: 1rem;
+ padding: 0;
+ margin: 1rem 0 0;
+ list-style: none;
+ }
+}
+.hg-community-member {
+ min-width: 0;
+ padding: 0;
+ &__link {
+ display: flex;
+ min-height: 100%;
+ flex-direction: column;
+ align-items: center;
+ gap: .65rem;
+ padding: 1rem .75rem;
+ color: inherit;
+ text-align: center;
+ text-decoration: none;
+ border: 1px solid var(--td-border-color);
+ border-radius: var(--td-card-border-radius, .75rem);
+ background: var(--td-card-bg, var(--bs-body-bg));
+ &:hover, &:focus-visible {
+ color: var(--td-link-color, var(--bs-link-color));
+ border-color: currentColor;
+ }
+ }
+ &__avatar {
+ position: relative;
+ display: grid;
+ width: 8rem;
+ height: 8rem;
+ overflow: hidden;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--td-secondary-bg, var(--bs-secondary-bg));
+ img, .hg-community-member__initials { position: absolute; inset: 0; width: 100%; height: 100%; }
+ img { object-fit: cover; }
+ }
+ &__initials {
+ display: grid;
+ place-items: center;
+ font-size: 1.65rem;
+ font-weight: 700;
+ color: var(--td-body-color, var(--bs-body-color));
+ }
+ &__identity { max-width: 100%; overflow-wrap: anywhere; font-weight: 650; }
+ &__role-label { font-size: .8rem; color: var(--td-secondary-color, var(--bs-secondary-color)); }
+}
+@media (max-width: 1199.98px) {
+ .hg-community-members { --hg-community-columns: 3; }
+}
+@media (max-width: 767.98px) {
+ .hg-community-members { --hg-community-columns: 2; }
+ .hg-community-member__avatar { width: 6rem; height: 6rem; }
+}
diff --git a/content/cn/docs/_index.md b/content/cn/docs/_index.md
index 2e3503418..e75800bb3 100755
--- a/content/cn/docs/_index.md
+++ b/content/cn/docs/_index.md
@@ -2,6 +2,7 @@
title: "Documentation"
linkTitle: "Documentation"
weight: 20
+outputs: [HTML, RSS, print, markdown, LLMSFULL]
---
## Apache HugeGraph 文档
diff --git a/content/cn/docs/clients/_index.md b/content/cn/docs/clients/_index.md
index b31bade12..5fd06188d 100644
--- a/content/cn/docs/clients/_index.md
+++ b/content/cn/docs/clients/_index.md
@@ -1,7 +1,10 @@
---
title: "客户端与 API"
+description: "通过 Java 客户端、REST API、Gremlin Console 和其他客户端库连接 HugeGraph。"
linkTitle: "客户端与 API"
weight: 5
+search_keywords: [HugeGraph 客户端, Java 客户端, 客户端库]
+search_boost: 1.5
---
本节包含 REST API、Gremlin Console 和客户端说明。当前 Server REST API 使用图空间和图名称组成资源路径;具体路径以各 API 页面和 Server 的 OpenAPI 页面为准。
diff --git a/content/cn/docs/clients/restful-api/_index.md b/content/cn/docs/clients/restful-api/_index.md
index 6094321b2..ab79ed10b 100644
--- a/content/cn/docs/clients/restful-api/_index.md
+++ b/content/cn/docs/clients/restful-api/_index.md
@@ -2,6 +2,8 @@
title: "HugeGraph RESTful API"
linkTitle: "RESTful API"
weight: 1
+search_keywords: [HugeGraph REST API, RESTful API, OpenAPI]
+search_boost: 1.7
---
> ⚠️ **版本兼容性说明**
diff --git a/content/cn/docs/clients/restful-api/vertex.md b/content/cn/docs/clients/restful-api/vertex.md
index 1318da72f..1d1158b28 100644
--- a/content/cn/docs/clients/restful-api/vertex.md
+++ b/content/cn/docs/clients/restful-api/vertex.md
@@ -5,7 +5,7 @@ weight: 7
description: "Vertex(顶点)REST 接口:创建、查询、更新和删除图中的顶点数据,支持批量操作和条件过滤。"
---
-### 2.1 Vertex
+### 2.1 Vertex {#vertex-api}
顶点类型中的 `Id` 策略决定了顶点的 `Id` 类型,其对应的 `id` 类型如下:
@@ -16,6 +16,7 @@ description: "Vertex(顶点)REST 接口:创建、查询、更新和删除图
| CUSTOMIZE_STRING | string |
| CUSTOMIZE_NUMBER | number |
| CUSTOMIZE_UUID | uuid |
+{#vertex-id-strategy .full-width caption="顶点 ID 策略"}
顶点的 `GET/PUT/DELETE` API 中 url 的 id 部分应该传入带有类型信息的 id 值,这个类型信息通过 json 串是否带引号来表示,也就是说:
@@ -41,7 +42,7 @@ schema.vertexLabel("software").properties("name", "lang", "price").primaryKeys("
schema.indexLabel("personByAge").onV("person").by("age").range().ifNotExist().create();
```
-#### 2.1.1 创建一个顶点
+#### 2.1.1 创建一个顶点 {#create-vertex}
##### Params
@@ -58,7 +59,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices
##### Request Body
-```json
+```json {filename="request.json" wrap=true}
{
"label": "person",
"properties": {
@@ -76,7 +77,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices
##### Response Body
-```json
+```json {filename="response.json" wrap=true}
{
"id": "1:marko",
"label": "person",
diff --git a/content/cn/docs/config/config-authentication.md b/content/cn/docs/config/config-authentication.md
index a6b67d1b5..481edd503 100644
--- a/content/cn/docs/config/config-authentication.md
+++ b/content/cn/docs/config/config-authentication.md
@@ -2,6 +2,8 @@
title: "HugeGraph 内置用户权限与扩展权限配置及使用"
linkTitle: "权限配置"
weight: 3
+search_keywords: [HugeGraph 认证, 权限配置, StandardAuthenticator]
+search_boost: 1.7
---
### 概述
diff --git a/content/cn/docs/config/config-guide.md b/content/cn/docs/config/config-guide.md
index c8d8d3c94..7d76a80f6 100644
--- a/content/cn/docs/config/config-guide.md
+++ b/content/cn/docs/config/config-guide.md
@@ -2,6 +2,8 @@
title: "Server 启动指南"
linkTitle: "Server 启动指南"
weight: 1
+search_keywords: [HugeGraph 配置, Server 配置, 配置指南]
+search_boost: 1.6
---
### 1 概述
@@ -21,7 +23,7 @@ HugeGraphServer 内部集成了 GremlinServer 和 RestServer,而 gremlin-serve
`gremlin-server.yaml` 的主要结构如下。示例省略了部分导入项;完整内容以发布包中的文件为准。
-```yaml
+```yaml {filename="conf/gremlin-server.yaml" wrap=true collapse=18}
# host and port of gremlin server, need to be consistent with host and port in rest-server.properties
#host: 127.0.0.1
#port: 8182
diff --git a/content/cn/docs/download/download.md b/content/cn/docs/download/download.md
index 831c22faa..a37a6c96c 100644
--- a/content/cn/docs/download/download.md
+++ b/content/cn/docs/download/download.md
@@ -2,6 +2,8 @@
title: "下载 Apache HugeGraph"
linkTitle: "Download"
weight: 2
+search_keywords: [HugeGraph 下载, 发布包, SHA512]
+search_boost: 3
---
> 指南:
@@ -11,25 +13,11 @@ weight: 2
> - 检查哈希 (SHA512)、签名的说明在 [版本验证](/docs/contribution-guidelines/validate-release/) 页面, 也可参考 [ASF 验证说明](https://www.apache.org/dyn/closer.cgi#verify)
> - 注: HugeGraph 所有组件版本号已保持一致, `client/loader/hubble/common` 等 maven 仓库版本号同理, 依赖引用可参考 [maven 示例](https://github.com/apache/hugegraph-toolchain#maven-dependencies)
> - 兼容说明: HugeGraph 于 2026 年 1 月毕业后,下载路径已从 `/incubator/hugegraph` 迁移到 `/hugegraph`。历史版本的发布文件名可能仍包含 `-incubating-`。
+> - 从源码构建请参考 [编译构建说明](/docs/quickstart/hugegraph/hugegraph-server/)
-### 最新版本 1.7.0
+### 最新版本
-- Release Date: 2025-11-28
-- [Release Notes](/docs/changelog/hugegraph-1.7.0-release-notes/)
-
-#### 二进制包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-#### 源码包
-
-Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/).
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](htt [...]
+{{< asf-downloads latest >}}
---
@@ -41,75 +29,4 @@ Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/
> 2. `1.3.0` 是最后一个兼容 Java8 的主版本, 请尽早使用/迁移运行时为 Java11 (低版本 Java 有潜在更多的 SEC 风险和性能影响)
> 3. 从版本 `1.5.0` 开始,需要 Java11 运行时环境
-#### 1.5.0
-
-- Release Date: 2024-12-10
-- [Release Notes](/docs/changelog/hugegraph-1.5.0-release-notes/)
-
-##### 二进制包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### 源码包
-
-Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/).
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](htt [...]
-
-#### 1.3.0
-
-- Release Date: 2024-04-01
-- [Release Notes](/docs/changelog/hugegraph-1.3.0-release-notes/)
-
-##### 二进制包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### 源码包
-
-Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/).
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](htt [...]
-
-
-#### 1.2.0
-
-- Release Date: 2023-12-28
-- [Release Notes](/docs/changelog/hugegraph-1.2.0-release-notes/)
-
-##### 二进制包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### 源码包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](htt [...]
-
-#### 1.0.0
-
-- Release Date: 2023-02-22
-- [Release Notes](/docs/changelog/hugegraph-1.0.0-release-notes/)
-
-##### 二进制包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### 源码包
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](htt [...]
+{{< asf-downloads archived >}}
diff --git a/content/cn/docs/introduction/_index.md b/content/cn/docs/introduction/_index.md
index d57559c4d..72a07c23d 100644
--- a/content/cn/docs/introduction/_index.md
+++ b/content/cn/docs/introduction/_index.md
@@ -2,6 +2,8 @@
title: "Apache HugeGraph 介绍"
linkTitle: "系统介绍"
weight: 1
+search_keywords: [HugeGraph 介绍, 图数据库简介, 系统架构]
+search_boost: 3
aliases:
# Hugo 0.165 prefixes aliases with the current language path.
- /docs/introduction/readme/
diff --git a/content/cn/docs/quickstart/computing/hugegraph-computer.md b/content/cn/docs/quickstart/computing/hugegraph-computer.md
index 297d0f7fc..134e9f3b1 100644
--- a/content/cn/docs/quickstart/computing/hugegraph-computer.md
+++ b/content/cn/docs/quickstart/computing/hugegraph-computer.md
@@ -2,6 +2,8 @@
title: "HugeGraph-Computer Quick Start"
linkTitle: "使用 Computer 进行 OLAP 分析"
weight: 2
+search_keywords: [HugeGraph Computer, 图计算, OLAP]
+search_boost: 1.6
---
## 1 HugeGraph-Computer 概述
diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md b/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md
index 34d5db1f0..3ab0ab64f 100644
--- a/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md
+++ b/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md
@@ -3,10 +3,10 @@ title: "HugeGraph-Store Quick Start"
linkTitle: "安装/构建 HugeGraph-Store"
weight: 3
search_keywords:
+ - HugeGraph HStore
+ - 分布式存储
- server.port
- - REST 端口
- - Store REST 端口
-search_boost: 1.5
+search_boost: 1.6
---
### 1 HugeGraph-Store 概述
diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md b/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md
index 532d11f89..403283cb3 100644
--- a/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md
+++ b/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md
@@ -2,6 +2,8 @@
title: "HugeGraph-PD Quick Start"
linkTitle: "安装/构建 HugeGraph-PD"
weight: 2
+search_keywords: [HugeGraph PD, 元数据管理, 集群调度]
+search_boost: 1.6
---
### 1 HugeGraph-PD 概述
diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-server.md b/content/cn/docs/quickstart/hugegraph/hugegraph-server.md
index 1e08171ff..390a137d7 100644
--- a/content/cn/docs/quickstart/hugegraph/hugegraph-server.md
+++ b/content/cn/docs/quickstart/hugegraph/hugegraph-server.md
@@ -2,6 +2,8 @@
title: "HugeGraph Server 快速开始"
linkTitle: "安装/构建 HugeGraph Server"
weight: 1
+search_keywords: [HugeGraph Server, Server 快速开始, 图数据库服务]
+search_boost: 1.7
aliases:
- /docs/quickstart/hugegraph-server/
---
@@ -34,10 +36,11 @@ HugeGraph 1.7.0 中的 `hugegraph-server` 模块使用 Java 11 编译,运行
有四种方式可以部署 Server 服务:
-- 方式 1:使用 Docker 容器 (便于**测试**)
-- 方式 2:下载 tar 包
-- 方式 3:源码编译
-- 方式 4:使用 tools 工具部署 (Outdated)
+1. 使用 Docker 容器进行测试或开发。
+1. 下载二进制 tar 包。
+1. 从源码编译。
+1. 使用已过时的一键部署工具。
+{.steps}
> 不要把 Gremlin、Cypher 等查询接口直接暴露到公网。生产环境应启用[认证与授权](/cn/docs/config/config-authentication/),限制网络访问并保留审计日志;部署建议见[安全指南](/cn/docs/guides/security/)。
@@ -72,7 +75,7 @@ HugeGraph 1.7.0 中的 `hugegraph-server` 模块使用 Java 11 编译,运行
| HA 参考 | `docker-compose-3pd-3store-3server.yml` | 3 PD + 3 Store + 3 Server + 1 Hubble |
| 最小 HStore 拓扑的源码构建覆盖文件 | `docker-compose.dev.yml` | (需与 `docker-compose-hstore.yml` 一起使用) |
-```bash
+```bash {filename="docker/docker-compose.yml" wrap=true}
cd hugegraph/docker
# 注意版本号请随时保持更新 → 1.x.0
HUGEGRAPH_VERSION=1.7.0 docker compose -f docker-compose.yml up -d --wait
@@ -92,7 +95,7 @@ compose 文件从 `HUGEGRAPH_ADMIN_PASSWORD` 读取管理员密码,从 `HUGEGR
### 3.2 下载 tar 包
-```bash
+```bash {filename="download-release.sh" wrap=true collapse=2}
# 1.7.0 是项目孵化期发布的历史版本,因此文件名仍带 incubating
wget https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz
tar zxf apache-hugegraph-incubating-1.7.0.tar.gz
@@ -104,7 +107,7 @@ tar zxf apache-hugegraph-incubating-1.7.0.tar.gz
下载 HugeGraph 源代码
-```bash
+```bash {filename="build-from-source.sh" wrap=true collapse=2}
git clone https://github.com/apache/hugegraph.git
```
diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md
index 92f2ac76e..313e5173c 100644
--- a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md
+++ b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md
@@ -1,7 +1,10 @@
---
title: "HugeGraph-Hubble Quick Start"
+description: "部署 HugeGraph-Hubble,进行图可视化、元数据管理、数据导入,以及 Gremlin 或 Cypher 查询。"
linkTitle: "使用 Hubble 实现图可视化"
weight: 1
+search_keywords: [HugeGraph Hubble, 图可视化, Web 管理界面]
+search_boost: 1.6
---
### 1 HugeGraph-Hubble 概述
diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-loader.md b/content/cn/docs/quickstart/toolchain/hugegraph-loader.md
index d58ff7785..5a9916bdc 100644
--- a/content/cn/docs/quickstart/toolchain/hugegraph-loader.md
+++ b/content/cn/docs/quickstart/toolchain/hugegraph-loader.md
@@ -2,6 +2,8 @@
title: "HugeGraph-Loader Quick Start"
linkTitle: "使用 Loader 实时/流式导入数据"
weight: 2
+search_keywords: [HugeGraph Loader, 批量导入, 数据导入]
+search_boost: 1.6
---
### 1 HugeGraph-Loader 概述
diff --git a/content/en/docs/_index.md b/content/en/docs/_index.md
index a9cd5fbca..7b1c7b8ee 100755
--- a/content/en/docs/_index.md
+++ b/content/en/docs/_index.md
@@ -2,6 +2,7 @@
title: "Documentation"
linkTitle: "Documentation"
weight: 20
+outputs: [HTML, RSS, print, markdown, LLMSFULL]
---
## Apache HugeGraph Documentation
diff --git a/content/en/docs/clients/_index.md b/content/en/docs/clients/_index.md
index 48a709f4f..3ac8c6fc2 100644
--- a/content/en/docs/clients/_index.md
+++ b/content/en/docs/clients/_index.md
@@ -1,7 +1,10 @@
---
title: "Clients and APIs"
+description: "Connect to HugeGraph with the Java client, REST API, Gremlin Console, and other client libraries."
linkTitle: "Clients and APIs"
weight: 5
+search_keywords: [HugeGraph clients, Java client, client libraries]
+search_boost: 1.5
---
This section covers the REST API, Gremlin Console, and client libraries. The current Server REST API identifies graph resources with both a graph space and a graph name. Refer to each API page and the Server OpenAPI page for the exact paths.
diff --git a/content/en/docs/clients/restful-api/_index.md b/content/en/docs/clients/restful-api/_index.md
index 7c35ce9e3..3b651a5e8 100644
--- a/content/en/docs/clients/restful-api/_index.md
+++ b/content/en/docs/clients/restful-api/_index.md
@@ -2,6 +2,8 @@
title: "HugeGraph RESTful API"
linkTitle: "RESTful API"
weight: 1
+search_keywords: [HugeGraph REST API, RESTful API, OpenAPI]
+search_boost: 1.7
---
> ⚠️ **Version compatibility notes**
diff --git a/content/en/docs/clients/restful-api/vertex.md b/content/en/docs/clients/restful-api/vertex.md
index 52d604c4e..207d9ac50 100644
--- a/content/en/docs/clients/restful-api/vertex.md
+++ b/content/en/docs/clients/restful-api/vertex.md
@@ -5,7 +5,7 @@ weight: 7
description: "Vertex REST API: Create, query, update, and delete vertex data in the graph with support for batch operations and conditional filtering."
---
-### 2.1 Vertex
+### 2.1 Vertex {#vertex-api}
In vertex types, the `Id` strategy determines the type of the vertex `Id`, with the corresponding relationships as follows:
@@ -16,6 +16,7 @@ In vertex types, the `Id` strategy determines the type of the vertex `Id`, with
| CUSTOMIZE_STRING | string |
| CUSTOMIZE_NUMBER | number |
| CUSTOMIZE_UUID | uuid |
+{#vertex-id-strategy .full-width caption="Vertex ID strategies"}
For the `GET/PUT/DELETE` API of a vertex, the id part in the URL should be passed as the id value with type information. This type information is indicated by whether the JSON string is enclosed in quotes, meaning:
@@ -41,7 +42,7 @@ schema.vertexLabel("software").properties("name", "lang", "price").primaryKeys("
schema.indexLabel("personByAge").onV("person").by("age").range().ifNotExist().create();
```
-#### 2.1.1 Create a vertex
+#### 2.1.1 Create a vertex {#create-vertex}
##### Method & Url
@@ -51,7 +52,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices
##### Request Body
-```json
+```json {filename="request.json" wrap=true}
{
"label": "person",
"properties": {
@@ -69,7 +70,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices
##### Response Body
-```json
+```json {filename="response.json" wrap=true}
{
"id": "1:marko",
"label": "person",
diff --git a/content/en/docs/config/config-authentication.md b/content/en/docs/config/config-authentication.md
index f5de712c5..2461c43ea 100644
--- a/content/en/docs/config/config-authentication.md
+++ b/content/en/docs/config/config-authentication.md
@@ -2,6 +2,8 @@
title: "Built-in User Authentication and Authorization Configuration and Usage in HugeGraph"
linkTitle: "Config Authentication"
weight: 3
+search_keywords: [HugeGraph authentication, authorization, StandardAuthenticator]
+search_boost: 1.7
---
### Overview
diff --git a/content/en/docs/config/config-guide.md b/content/en/docs/config/config-guide.md
index 3e24f49ed..d8a583f42 100644
--- a/content/en/docs/config/config-guide.md
+++ b/content/en/docs/config/config-guide.md
@@ -2,6 +2,8 @@
title: "Server Startup Guide"
linkTitle: "Server Startup Guide"
weight: 1
+search_keywords: [HugeGraph configuration, server config, configuration guide]
+search_boost: 1.6
---
### 1 Overview
@@ -21,7 +23,7 @@ Now let's introduce these three configuration files one by one.
The main structure of `gremlin-server.yaml` is shown below. Some imports are omitted from this example; refer to the file included in the release package for the complete content.
-```yaml
+```yaml {filename="conf/gremlin-server.yaml" wrap=true collapse=18}
# host and port of gremlin server, need to be consistent with host and port in rest-server.properties
#host: 127.0.0.1
#port: 8182
diff --git a/content/en/docs/download/download.md b/content/en/docs/download/download.md
index a23907c5c..46e5f3103 100644
--- a/content/en/docs/download/download.md
+++ b/content/en/docs/download/download.md
@@ -2,6 +2,8 @@
title: "Download Apache HugeGraph"
linkTitle: "Download"
weight: 2
+search_keywords: [HugeGraph download, release artifacts, SHA512]
+search_boost: 3
---
@@ -12,25 +14,11 @@ weight: 2
> - Instructions for checking hash (SHA512) and signatures are on the [Validate Release](/docs/contribution-guidelines/validate-release) page, and you can also refer to [ASF official instructions](https://www.apache.org/dyn/closer.cgi#verify).
> - Note: The version numbers of all components of HugeGraph have been kept consistent, and the version numbers of Maven repositories such as `client/loader/hubble/common` are the same. You can refer to these for dependency references [maven example](https://github.com/apache/hugegraph-toolchain#maven-dependencies).
> - Compatibility note: after HugeGraph graduated in January 2026, download paths moved from `/incubator/hugegraph` to `/hugegraph`. Historical release file names may still include `-incubating-`.
+> - To build from source, refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server).
-### Latest Version 1.7.0
+### Latest Version
-- Release Date: 2025-11-28
-- [Release Notes](/docs/changelog/hugegraph-1.7.0-release-notes)
-
-#### Binary Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-#### Source Packages
-
-Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server).
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](htt [...]
+{{< asf-downloads latest >}}
---
@@ -38,70 +26,4 @@ Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server)
> Note: `1.3.0` is the last major version compatible with Java8, please switch to or migrate to Java11 as soon as possible (lower versions of Java have potentially more SEC risks and performance impacts). Starting from version `1.5.0`, a Java11 runtime environment is required.
-#### 1.5.0
-
-- Release Date: 2024-12-10
-- [Release Notes](/docs/changelog/hugegraph-1.5.0-release-notes)
-
-##### Binary Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### Source Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](htt [...]
-
-#### 1.3.0
-
-- Release Date: 2024-04-01
-- [Release Notes](/docs/changelog/hugegraph-1.3.0-release-notes)
-
-##### Binary Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### Source Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](htt [...]
-
-#### 1.2.0
-
-- Release Date: 2023-12-28
-- [Release Notes](/docs/changelog/hugegraph-1.2.0-release-notes)
-
-##### Binary Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### Source Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](htt [...]
-
-#### 1.0.0
-
-- Release Date: 2023-02-22
-- [Release Notes](/docs/changelog/hugegraph-1.0.0-release-notes)
-
-##### Binary Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------- [...]
-| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.a [...]
-
-##### Source Packages
-
-| Server | Toolchain [...]
-|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------- [...]
-| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](htt [...]
+{{< asf-downloads archived >}}
diff --git a/content/en/docs/introduction/_index.md b/content/en/docs/introduction/_index.md
index e83b690e1..87b87f30d 100644
--- a/content/en/docs/introduction/_index.md
+++ b/content/en/docs/introduction/_index.md
@@ -2,6 +2,8 @@
title: "Apache HugeGraph Introduction"
linkTitle: "System Introduction"
weight: 1
+search_keywords: [HugeGraph overview, graph database introduction, architecture]
+search_boost: 3
aliases:
# Hugo 0.165 prefixes aliases with the current language path.
- /docs/introduction/readme/
diff --git a/content/en/docs/quickstart/computing/hugegraph-computer.md b/content/en/docs/quickstart/computing/hugegraph-computer.md
index a153e6b43..8c445841e 100644
--- a/content/en/docs/quickstart/computing/hugegraph-computer.md
+++ b/content/en/docs/quickstart/computing/hugegraph-computer.md
@@ -2,6 +2,8 @@
title: "HugeGraph-Computer Quick Start"
linkTitle: "Analysis with HugeGraph-Computer"
weight: 2
+search_keywords: [HugeGraph Computer, graph computing, OLAP]
+search_boost: 1.6
---
## 1 HugeGraph-Computer Overview
diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md b/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md
index cabd39d74..abdc14dfd 100644
--- a/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md
+++ b/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md
@@ -3,10 +3,10 @@ title: "HugeGraph-Store Quick Start"
linkTitle: "Install/Build HugeGraph-Store"
weight: 3
search_keywords:
+ - HugeGraph HStore
+ - distributed storage
- server.port
- - REST port
- - Store REST port
-search_boost: 1.5
+search_boost: 1.6
---
### 1 HugeGraph-Store Overview
diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-pd.md b/content/en/docs/quickstart/hugegraph/hugegraph-pd.md
index 2a8cefd7a..8a5d56afa 100644
--- a/content/en/docs/quickstart/hugegraph/hugegraph-pd.md
+++ b/content/en/docs/quickstart/hugegraph/hugegraph-pd.md
@@ -2,6 +2,8 @@
title: "HugeGraph-PD Quick Start"
linkTitle: "Install/Build HugeGraph-PD"
weight: 2
+search_keywords: [HugeGraph PD, placement driver, cluster metadata]
+search_boost: 1.6
---
### 1 HugeGraph-PD Overview
diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-server.md b/content/en/docs/quickstart/hugegraph/hugegraph-server.md
index 5288908f5..ec50175f7 100644
--- a/content/en/docs/quickstart/hugegraph/hugegraph-server.md
+++ b/content/en/docs/quickstart/hugegraph/hugegraph-server.md
@@ -2,6 +2,8 @@
title: "HugeGraph Server Quick Start"
linkTitle: "Install/Build HugeGraph Server"
weight: 1
+search_keywords: [HugeGraph Server, server quickstart, graph database]
+search_boost: 1.7
aliases:
- /docs/quickstart/hugegraph-server/
---
@@ -34,10 +36,11 @@ The `hugegraph-server` module in HugeGraph 1.7.0 is compiled with Java 11. Runni
There are four ways to deploy the Server service:
-- Method 1: Use Docker container (Convenient for Test/Dev)
-- Method 2: Download the binary tarball
-- Method 3: Source code compilation
-- Method 4: One-click deployment
+1. Use a Docker container for test or development.
+1. Download the binary tarball.
+1. Compile the source code.
+1. Use the legacy one-click deployment tool.
+{.steps}
> Do not expose Gremlin, Cypher, or other query endpoints directly to the public Internet. In production, enable [authentication and authorization](/docs/config/config-authentication/), restrict network access, and retain audit logs. See the [Security Guide](/docs/guides/security/) for deployment guidance.
@@ -70,7 +73,7 @@ Four compose files are available in the [`docker/`](https://github.com/apache/hu
| HA reference | `docker-compose-3pd-3store-3server.yml` | 3 PD + 3 Store + 3 Server + 1 Hubble |
| Source build override for the minimal HStore topology | `docker-compose.dev.yml` | (used together with `docker-compose-hstore.yml`) |
-```bash
+```bash {filename="docker/docker-compose.yml" wrap=true}
cd hugegraph/docker
# Keep the version aligned with the latest release, for example 1.x.0
HUGEGRAPH_VERSION=1.7.0 docker compose -f docker-compose.yml up -d --wait
@@ -91,7 +94,7 @@ See [docker/README.md](https://github.com/apache/hugegraph/blob/master/docker/RE
### 3.2 Download the binary tarball
You could download the binary tarball from the download page of the ASF site like this:
-```bash
+```bash {filename="download-and-verify.sh" wrap=true collapse=5}
# 1.7.0 is a historical release from the incubation period, so its file name still includes "incubating"
wget https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz
tar zxf apache-hugegraph-incubating-1.7.0.tar.gz
@@ -109,7 +112,7 @@ Download HugeGraph **source code** in either of the following 2 ways (so as the
- download the stable/release version from the ASF site
- clone the unstable/latest version by GitBox(ASF) or GitHub
-```bash
+```bash {filename="build-from-source.sh" wrap=true collapse=5}
# Way 1. download release package from the ASF site
wget https://downloads.apache.org/hugegraph/{version}/apache-hugegraph-incubating-src-{version}.tar.gz
tar zxf *hugegraph*.tar.gz
diff --git a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md
index da7bb23e6..fcac006c6 100644
--- a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md
+++ b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md
@@ -1,7 +1,10 @@
---
title: "HugeGraph-Hubble Quick Start"
+description: "Deploy HugeGraph-Hubble for graph visualization, schema management, data import, and Gremlin or Cypher queries."
linkTitle: "Visual with HugeGraph-Hubble"
weight: 1
+search_keywords: [HugeGraph Hubble, graph visualization, web console]
+search_boost: 1.6
---
### 1 HugeGraph-Hubble Overview
diff --git a/content/en/docs/quickstart/toolchain/hugegraph-loader.md b/content/en/docs/quickstart/toolchain/hugegraph-loader.md
index 5cf06bd12..ce448b5a7 100644
--- a/content/en/docs/quickstart/toolchain/hugegraph-loader.md
+++ b/content/en/docs/quickstart/toolchain/hugegraph-loader.md
@@ -2,6 +2,8 @@
title: "HugeGraph-Loader Quick Start"
linkTitle: "Load data with HugeGraph-Loader"
weight: 2
+search_keywords: [HugeGraph Loader, bulk import, data loading]
+search_boost: 1.6
---
### 1 HugeGraph-Loader Overview
diff --git a/contribution.md b/contribution.md
index 864c4a675..cd95f7ce4 100644
--- a/contribution.md
+++ b/contribution.md
@@ -37,7 +37,7 @@ The module graph must contain exactly the pinned `github.com/pgsty/oink@v1.0.0`
```bash
git clone https://github.com/apache/hugegraph-doc.git
cd hugegraph-doc
-hugo server
+scripts/hugo.sh server
```
Open <http://localhost:1313/>. The local preview includes the language-aware search index so search behavior can be checked before publication.
@@ -47,14 +47,22 @@ Open <http://localhost:1313/>. The local preview includes the language-aware sea
Run the same warning-strict build used by CI:
```bash
-hugo --cleanDestinationDir --gc --minify \
- --environment production \
- --printPathWarnings \
- --printI18nWarnings \
- --panicOnWarning \
- --logLevel info
+scripts/hugo.sh build
```
+The wrapper derives the complete version menu from `versions.json` before
+starting Hugo. Additional Hugo arguments are passed through unchanged, for
+example `scripts/hugo.sh server -p 8080`. Set `HG_DOC_VERSION`,
+`HG_DOC_SITE_ORIGIN`, or `HG_DOC_HISTORICAL_ORIGIN` only when validating a
+specific version or publication origin.
+
+The wrapper applies Hugo's configuration, environment, strict-warning,
+cleanup, and minification flags before your arguments; Hugo's last-wins flag
+handling means anything you append (for example `--logLevel debug`) takes
+effect. Use `--baseURL` or `HG_DOC_SITE_ORIGIN` when changing the rendered
+origin (the flag wins when both are set); a server `--port` is reflected in
+the generated local origin.
+
A successful command proves that Hugo rendered the configured outputs. It does not replace browser checks for navigation, search, language switching, accessibility, mobile layout, print, or Content Security Policy behavior.
## Repository structure
@@ -63,7 +71,7 @@ A successful command proves that Hugo rendered the configured outputs. It does n
- `hugo.yaml` owns routing, languages, outputs, search, navigation, and OINK parameters.
- `data/home/<language>.yaml` owns the bilingual homepage.
- `data/footer/<language>.yaml` owns the bilingual footer.
-- `i18n/cn.yaml` maps the preserved `cn` URL language key to the Simplified Chinese OINK interface catalogue.
+- `i18n/zh-CN.yaml` carries the Simplified Chinese OINK interface catalogue for the preserved `cn` URL language key; the file is named after the `zh-CN` locale because Hugo resolves translations by locale, not by the URL key.
- `assets/` and `static/` contain site-owned brand and compatibility assets.
OINK is a module dependency. Do not copy or edit generated module-cache files. Site-specific overrides belong in the corresponding root `layouts/`, `assets/`, or data path and require focused regression evidence.
@@ -71,4 +79,3 @@ OINK is a module dependency. Do not copy or edit generated module-cache files. S
## 中文说明
提交前请同时检查中英文页面、公开 URL、搜索结果和语言切换。视觉或导航变更必须提供修改前后的桌面与移动端截图。构建成功只证明模板可以渲染,不能替代真实浏览器、无障碍、打印和 CSP 检查。
-
diff --git a/data/community/github-map.json b/data/community/github-map.json
new file mode 100644
index 000000000..7529e5a32
--- /dev/null
+++ b/data/community/github-map.json
@@ -0,0 +1,4 @@
+{
+ "schema_version": 1,
+ "mappings": {}
+}
diff --git a/data/community/roster.json b/data/community/roster.json
new file mode 100644
index 000000000..413853d88
--- /dev/null
+++ b/data/community/roster.json
@@ -0,0 +1,209 @@
+{
+ "schema_version": 1,
+ "project": "hugegraph",
+ "retrieved_at": "2026-09-04T12:12:42Z",
+ "source": {
+ "committee": "https://whimsy.apache.org/public/committee-info.json",
+ "projects": "https://whimsy.apache.org/public/public_ldap_projects.json",
+ "people": "https://whimsy.apache.org/public/public_ldap_people.json",
+ "chair": "jermy",
+ "owners": [
+ "hxd",
+ "jermy",
+ "jin",
+ "lidongdai",
+ "linary",
+ "liyu",
+ "ming",
+ "ningjiang",
+ "panjuan",
+ "vaughn",
+ "vgalaxies",
+ "zhaocong"
+ ],
+ "members": [
+ "guoshoujing",
+ "hxd",
+ "jermy",
+ "jin",
+ "jsong010123",
+ "leizou",
+ "lidongdai",
+ "linary",
+ "liuxiaocs",
+ "liyu",
+ "ming",
+ "ningjiang",
+ "panjuan",
+ "pengjunzhi",
+ "spica",
+ "vaughn",
+ "vgalaxies",
+ "vichayturen",
+ "wangjing",
+ "yangjiaqi",
+ "zhangyi89817",
+ "zhaocong"
+ ]
+ },
+ "roles": {
+ "pmc": [
+ {
+ "asf_id": "jermy",
+ "name": "Jermy Li",
+ "initials": "JL",
+ "chair": true,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=jermy"
+ },
+ {
+ "asf_id": "zhaocong",
+ "name": "Cong Zhao",
+ "initials": "CZ",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=zhaocong"
+ },
+ {
+ "asf_id": "jin",
+ "name": "Imba Jin",
+ "initials": "IJ",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=jin"
+ },
+ {
+ "asf_id": "panjuan",
+ "name": "Juan Pan",
+ "initials": "JP",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=panjuan"
+ },
+ {
+ "asf_id": "lidongdai",
+ "name": "Lidong Dai",
+ "initials": "LD",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=lidongdai"
+ },
+ {
+ "asf_id": "linary",
+ "name": "NingRui Li",
+ "initials": "NL",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=linary"
+ },
+ {
+ "asf_id": "ming",
+ "name": "Simon",
+ "initials": "S",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=ming"
+ },
+ {
+ "asf_id": "ningjiang",
+ "name": "Willem Ning Jiang",
+ "initials": "WN",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=ningjiang"
+ },
+ {
+ "asf_id": "hxd",
+ "name": "Xiangdong Huang",
+ "initials": "XH",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=hxd"
+ },
+ {
+ "asf_id": "vaughn",
+ "name": "Yan Zhang",
+ "initials": "YZ",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=vaughn"
+ },
+ {
+ "asf_id": "liyu",
+ "name": "Yu Li",
+ "initials": "YL",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=liyu"
+ },
+ {
+ "asf_id": "vgalaxies",
+ "name": "Yuchen Ding",
+ "initials": "YD",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=vgalaxies"
+ }
+ ],
+ "committers": [
+ {
+ "asf_id": "yangjiaqi",
+ "name": "Jacky Yang",
+ "initials": "JY",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=yangjiaqi"
+ },
+ {
+ "asf_id": "jsong010123",
+ "name": "Jason",
+ "initials": "J",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=jsong010123"
+ },
+ {
+ "asf_id": "wangjing",
+ "name": "Jing Wang",
+ "initials": "JW",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=wangjing"
+ },
+ {
+ "asf_id": "pengjunzhi",
+ "name": "Junzhi Peng",
+ "initials": "JP",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=pengjunzhi"
+ },
+ {
+ "asf_id": "vichayturen",
+ "name": "Kaiyichen Wei",
+ "initials": "KW",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=vichayturen"
+ },
+ {
+ "asf_id": "leizou",
+ "name": "Lei Zou",
+ "initials": "LZ",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=leizou"
+ },
+ {
+ "asf_id": "guoshoujing",
+ "name": "Shoujing Guo",
+ "initials": "SG",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=guoshoujing"
+ },
+ {
+ "asf_id": "liuxiaocs",
+ "name": "Xiao Liu",
+ "initials": "XL",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=liuxiaocs"
+ },
+ {
+ "asf_id": "zhangyi89817",
+ "name": "Yi Zhang",
+ "initials": "YZ",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=zhangyi89817"
+ },
+ {
+ "asf_id": "spica",
+ "name": "Zhe Wang",
+ "initials": "ZW",
+ "chair": false,
+ "profile_url": "https://people.apache.org/phonebook.html?uid=spica"
+ }
+ ]
+ }
+}
diff --git a/data/downloads/asf.json b/data/downloads/asf.json
new file mode 100644
index 000000000..af81244a6
--- /dev/null
+++ b/data/downloads/asf.json
@@ -0,0 +1,53 @@
+{
+ "$comment": "Single source of truth for the ASF release download facts. Both language download pages render from this file through layouts/_partials/asf-downloads.html; do not edit the page tables by hand. Every filename was verified against https://downloads.apache.org/hugegraph/<version>/ on 2026-09-13. file = <component prefix>[-incubating]-<version>[-src].tar.gz where -incubating applies when the release has incubating: true; mirror = https://www.apache.org/dyn/closer.lua/<dist_pat [...]
+ "dist_path": "hugegraph",
+ "components": {
+ "server": { "prefix": "apache-hugegraph", "label_key": "download_component_server" },
+ "toolchain": { "prefix": "apache-hugegraph-toolchain", "label_key": "download_component_toolchain" },
+ "ai": { "prefix": "apache-hugegraph-ai", "label_key": "download_component_ai" },
+ "computer": { "prefix": "apache-hugegraph-computer", "label_key": "download_component_computer" },
+ "commons": { "prefix": "apache-hugegraph-commons", "label_key": "download_component_commons" }
+ },
+ "releases": [
+ {
+ "version": "1.7.0",
+ "date": "2025-11-28",
+ "latest": true,
+ "incubating": true,
+ "binary": ["server", "toolchain"],
+ "source": ["server", "toolchain", "ai", "computer"]
+ },
+ {
+ "version": "1.5.0",
+ "date": "2024-12-10",
+ "latest": false,
+ "incubating": true,
+ "binary": ["server", "toolchain"],
+ "source": ["server", "toolchain", "ai", "computer"]
+ },
+ {
+ "version": "1.3.0",
+ "date": "2024-04-01",
+ "latest": false,
+ "incubating": true,
+ "binary": ["server", "toolchain"],
+ "source": ["server", "toolchain", "ai", "commons"]
+ },
+ {
+ "version": "1.2.0",
+ "date": "2023-12-28",
+ "latest": false,
+ "incubating": true,
+ "binary": ["server", "toolchain"],
+ "source": ["server", "toolchain", "computer", "commons"]
+ },
+ {
+ "version": "1.0.0",
+ "date": "2023-02-22",
+ "latest": false,
+ "incubating": true,
+ "binary": ["server", "toolchain", "computer"],
+ "source": ["server", "toolchain", "computer", "commons"]
+ }
+ ]
+}
diff --git a/data/landing/community/cn.yaml b/data/landing/community/cn.yaml
index 9bf9f66f4..ec95ae75a 100644
--- a/data/landing/community/cn.yaml
+++ b/data/landing/community/cn.yaml
@@ -34,6 +34,11 @@ sections:
- [安全策略](/cn/docs/guides/security/) — 了解项目的安全问题报告流程。
- [贡献指南](/cn/docs/contribution-guidelines/) — 了解如何贡献代码和文档。
+ - type: community-members
+ partial: landing/sections/community-members.html
+ data:
+ id: project-members
+
- type: markdown
data:
title: 了解项目运作方式
diff --git a/data/landing/community/en.yaml b/data/landing/community/en.yaml
index 9f54604f7..d5b34325e 100644
--- a/data/landing/community/en.yaml
+++ b/data/landing/community/en.yaml
@@ -34,6 +34,11 @@ sections:
- [Security policy](/docs/guides/security/) — follow the project's security reporting process.
- [Contribution guidelines](/docs/contribution-guidelines/) — learn how to contribute code and documentation.
+ - type: community-members
+ partial: landing/sections/community-members.html
+ data:
+ id: project-members
+
- type: cta
data:
title: Learn how the project works
diff --git a/data/version_routes.json b/data/version_routes.json
new file mode 100644
index 000000000..bd7e4351e
--- /dev/null
+++ b/data/version_routes.json
@@ -0,0 +1,1513 @@
+{
+ "schemaVersion": 1,
+ "versions": [
+ "latest",
+ "1.7",
+ "1.5",
+ "1.3",
+ "1.0"
+ ],
+ "pages": {
+ "cn:": {
+ "latest": "cn/docs/",
+ "1.7": "cn/docs/",
+ "1.5": "cn/docs/",
+ "1.3": "cn/docs/",
+ "1.0": "cn/docs/"
+ },
+ "cn:changelog": {
+ "latest": "cn/docs/changelog/",
+ "1.7": "cn/docs/changelog/",
+ "1.5": "cn/docs/changelog/",
+ "1.3": "cn/docs/changelog/",
+ "1.0": "cn/docs/changelog/"
+ },
+ "cn:changelog/hugegraph-0.10.4-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.10.4-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.11.2-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.11.2-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.12.0-release-notes": {
+ "latest": "cn/docs/changelog/hugegraph-0.12.0-release-notes/",
+ "1.7": "cn/docs/changelog/hugegraph-0.12.0-release-notes/",
+ "1.5": "cn/docs/changelog/hugegraph-0.12.0-release-notes/",
+ "1.3": "cn/docs/changelog/hugegraph-0.12.0-release-notes/",
+ "1.0": "cn/docs/changelog/hugegraph-0.12.0-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.2-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.2-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.2.4-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.2.4-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.3.3-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.3.3-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.4.4-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.4.4-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.5.6-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.5.6-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.6.1-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.6.1-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.7.4-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.7.4-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.8.0-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.8.0-release-notes/"
+ },
+ "cn:changelog/hugegraph-0.9.2-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/changelog/hugegraph-0.9.2-release-notes/"
+ },
+ "cn:changelog/hugegraph-1.0.0-release-notes": {
+ "latest": "cn/docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.7": "cn/docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.5": "cn/docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.3": "cn/docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.0": "cn/docs/changelog/hugegraph-1.0.0-release-notes/"
+ },
+ "cn:changelog/hugegraph-1.2.0-release-notes": {
+ "latest": "cn/docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.7": "cn/docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.5": "cn/docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.3": "cn/docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.0": null
+ },
+ "cn:changelog/hugegraph-1.3.0-release-notes": {
+ "latest": "cn/docs/changelog/hugegraph-1.3.0-release-notes/",
+ "1.7": "cn/docs/changelog/hugegraph-1.3.0-release-notes/",
+ "1.5": "cn/docs/changelog/hugegraph-1.3.0-release-notes/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:changelog/hugegraph-1.5.0-release-notes": {
+ "latest": "cn/docs/changelog/hugegraph-1.5.0-release-notes/",
+ "1.7": "cn/docs/changelog/hugegraph-1.5.0-release-notes/",
+ "1.5": "cn/docs/changelog/hugegraph-1.5.0-release-notes/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:changelog/hugegraph-1.7.0-release-notes": {
+ "latest": "cn/docs/changelog/hugegraph-1.7.0-release-notes/",
+ "1.7": "cn/docs/changelog/hugegraph-1.7.0-release-notes/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:cla": {
+ "latest": "cn/docs/cla/",
+ "1.7": "cn/docs/cla/",
+ "1.5": "cn/docs/cla/",
+ "1.3": "cn/docs/cla/",
+ "1.0": "cn/docs/cla/"
+ },
+ "cn:clients": {
+ "latest": "cn/docs/clients/",
+ "1.7": "cn/docs/clients/",
+ "1.5": "cn/docs/clients/",
+ "1.3": "cn/docs/clients/",
+ "1.0": "cn/docs/clients/"
+ },
+ "cn:clients/gremlin-console": {
+ "latest": "cn/docs/clients/gremlin-console/",
+ "1.7": "cn/docs/clients/gremlin-console/",
+ "1.5": "cn/docs/clients/gremlin-console/",
+ "1.3": "cn/docs/clients/gremlin-console/",
+ "1.0": "cn/docs/clients/gremlin-console/"
+ },
+ "cn:clients/hugegraph-client": {
+ "latest": "cn/docs/clients/hugegraph-client/",
+ "1.7": "cn/docs/clients/hugegraph-client/",
+ "1.5": "cn/docs/clients/hugegraph-client/",
+ "1.3": "cn/docs/clients/hugegraph-client/",
+ "1.0": "cn/docs/clients/hugegraph-client/"
+ },
+ "cn:clients/restful-api": {
+ "latest": "cn/docs/clients/restful-api/",
+ "1.7": "cn/docs/clients/restful-api/",
+ "1.5": "cn/docs/clients/restful-api/",
+ "1.3": "cn/docs/clients/restful-api/",
+ "1.0": "cn/docs/clients/restful-api/"
+ },
+ "cn:clients/restful-api/auth": {
+ "latest": "cn/docs/clients/restful-api/auth/",
+ "1.7": "cn/docs/clients/restful-api/auth/",
+ "1.5": "cn/docs/clients/restful-api/auth/",
+ "1.3": "cn/docs/clients/restful-api/auth/",
+ "1.0": "cn/docs/clients/restful-api/auth/"
+ },
+ "cn:clients/restful-api/cypher": {
+ "latest": "cn/docs/clients/restful-api/cypher/",
+ "1.7": "cn/docs/clients/restful-api/cypher/",
+ "1.5": "cn/docs/clients/restful-api/cypher/",
+ "1.3": "cn/docs/clients/restful-api/cypher/",
+ "1.0": null
+ },
+ "cn:clients/restful-api/edge": {
+ "latest": "cn/docs/clients/restful-api/edge/",
+ "1.7": "cn/docs/clients/restful-api/edge/",
+ "1.5": "cn/docs/clients/restful-api/edge/",
+ "1.3": "cn/docs/clients/restful-api/edge/",
+ "1.0": "cn/docs/clients/restful-api/edge/"
+ },
+ "cn:clients/restful-api/edgelabel": {
+ "latest": "cn/docs/clients/restful-api/edgelabel/",
+ "1.7": "cn/docs/clients/restful-api/edgelabel/",
+ "1.5": "cn/docs/clients/restful-api/edgelabel/",
+ "1.3": "cn/docs/clients/restful-api/edgelabel/",
+ "1.0": "cn/docs/clients/restful-api/edgelabel/"
+ },
+ "cn:clients/restful-api/graphs": {
+ "latest": "cn/docs/clients/restful-api/graphs/",
+ "1.7": "cn/docs/clients/restful-api/graphs/",
+ "1.5": "cn/docs/clients/restful-api/graphs/",
+ "1.3": "cn/docs/clients/restful-api/graphs/",
+ "1.0": "cn/docs/clients/restful-api/graphs/"
+ },
+ "cn:clients/restful-api/graphspace": {
+ "latest": "cn/docs/clients/restful-api/graphspace/",
+ "1.7": "cn/docs/clients/restful-api/graphspace/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:clients/restful-api/gremlin": {
+ "latest": "cn/docs/clients/restful-api/gremlin/",
+ "1.7": "cn/docs/clients/restful-api/gremlin/",
+ "1.5": "cn/docs/clients/restful-api/gremlin/",
+ "1.3": "cn/docs/clients/restful-api/gremlin/",
+ "1.0": "cn/docs/clients/restful-api/gremlin/"
+ },
+ "cn:clients/restful-api/indexlabel": {
+ "latest": "cn/docs/clients/restful-api/indexlabel/",
+ "1.7": "cn/docs/clients/restful-api/indexlabel/",
+ "1.5": "cn/docs/clients/restful-api/indexlabel/",
+ "1.3": "cn/docs/clients/restful-api/indexlabel/",
+ "1.0": "cn/docs/clients/restful-api/indexlabel/"
+ },
+ "cn:clients/restful-api/metrics": {
+ "latest": "cn/docs/clients/restful-api/metrics/",
+ "1.7": "cn/docs/clients/restful-api/metrics/",
+ "1.5": "cn/docs/clients/restful-api/metrics/",
+ "1.3": "cn/docs/clients/restful-api/metrics/",
+ "1.0": null
+ },
+ "cn:clients/restful-api/other": {
+ "latest": "cn/docs/clients/restful-api/other/",
+ "1.7": "cn/docs/clients/restful-api/other/",
+ "1.5": "cn/docs/clients/restful-api/other/",
+ "1.3": "cn/docs/clients/restful-api/other/",
+ "1.0": "cn/docs/clients/restful-api/other/"
+ },
+ "cn:clients/restful-api/propertykey": {
+ "latest": "cn/docs/clients/restful-api/propertykey/",
+ "1.7": "cn/docs/clients/restful-api/propertykey/",
+ "1.5": "cn/docs/clients/restful-api/propertykey/",
+ "1.3": "cn/docs/clients/restful-api/propertykey/",
+ "1.0": "cn/docs/clients/restful-api/propertykey/"
+ },
+ "cn:clients/restful-api/rank": {
+ "latest": "cn/docs/clients/restful-api/rank/",
+ "1.7": "cn/docs/clients/restful-api/rank/",
+ "1.5": "cn/docs/clients/restful-api/rank/",
+ "1.3": "cn/docs/clients/restful-api/rank/",
+ "1.0": "cn/docs/clients/restful-api/rank/"
+ },
+ "cn:clients/restful-api/rebuild": {
+ "latest": "cn/docs/clients/restful-api/rebuild/",
+ "1.7": "cn/docs/clients/restful-api/rebuild/",
+ "1.5": "cn/docs/clients/restful-api/rebuild/",
+ "1.3": "cn/docs/clients/restful-api/rebuild/",
+ "1.0": "cn/docs/clients/restful-api/rebuild/"
+ },
+ "cn:clients/restful-api/schema": {
+ "latest": "cn/docs/clients/restful-api/schema/",
+ "1.7": "cn/docs/clients/restful-api/schema/",
+ "1.5": "cn/docs/clients/restful-api/schema/",
+ "1.3": "cn/docs/clients/restful-api/schema/",
+ "1.0": "cn/docs/clients/restful-api/schema/"
+ },
+ "cn:clients/restful-api/task": {
+ "latest": "cn/docs/clients/restful-api/task/",
+ "1.7": "cn/docs/clients/restful-api/task/",
+ "1.5": "cn/docs/clients/restful-api/task/",
+ "1.3": "cn/docs/clients/restful-api/task/",
+ "1.0": "cn/docs/clients/restful-api/task/"
+ },
+ "cn:clients/restful-api/traverser": {
+ "latest": "cn/docs/clients/restful-api/traverser/",
+ "1.7": "cn/docs/clients/restful-api/traverser/",
+ "1.5": "cn/docs/clients/restful-api/traverser/",
+ "1.3": "cn/docs/clients/restful-api/traverser/",
+ "1.0": "cn/docs/clients/restful-api/traverser/"
+ },
+ "cn:clients/restful-api/variable": {
+ "latest": "cn/docs/clients/restful-api/variable/",
+ "1.7": "cn/docs/clients/restful-api/variable/",
+ "1.5": "cn/docs/clients/restful-api/variable/",
+ "1.3": "cn/docs/clients/restful-api/variable/",
+ "1.0": "cn/docs/clients/restful-api/variable/"
+ },
+ "cn:clients/restful-api/vertex": {
+ "latest": "cn/docs/clients/restful-api/vertex/",
+ "1.7": "cn/docs/clients/restful-api/vertex/",
+ "1.5": "cn/docs/clients/restful-api/vertex/",
+ "1.3": "cn/docs/clients/restful-api/vertex/",
+ "1.0": "cn/docs/clients/restful-api/vertex/"
+ },
+ "cn:clients/restful-api/vertexlabel": {
+ "latest": "cn/docs/clients/restful-api/vertexlabel/",
+ "1.7": "cn/docs/clients/restful-api/vertexlabel/",
+ "1.5": "cn/docs/clients/restful-api/vertexlabel/",
+ "1.3": "cn/docs/clients/restful-api/vertexlabel/",
+ "1.0": "cn/docs/clients/restful-api/vertexlabel/"
+ },
+ "cn:config": {
+ "latest": "cn/docs/config/",
+ "1.7": "cn/docs/config/",
+ "1.5": "cn/docs/config/",
+ "1.3": "cn/docs/config/",
+ "1.0": "cn/docs/config/"
+ },
+ "cn:config/config-authentication": {
+ "latest": "cn/docs/config/config-authentication/",
+ "1.7": "cn/docs/config/config-authentication/",
+ "1.5": "cn/docs/config/config-authentication/",
+ "1.3": "cn/docs/config/config-authentication/",
+ "1.0": "cn/docs/config/config-authentication/"
+ },
+ "cn:config/config-backend-hbase": {
+ "latest": "cn/docs/config/config-backend-hbase/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:config/config-backend-hstore": {
+ "latest": "cn/docs/config/config-backend-hstore/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:config/config-backend-rocksdb": {
+ "latest": "cn/docs/config/config-backend-rocksdb/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:config/config-computer": {
+ "latest": null,
+ "1.7": "cn/docs/config/config-computer/",
+ "1.5": "cn/docs/config/config-computer/",
+ "1.3": "cn/docs/config/config-computer/",
+ "1.0": "cn/docs/config/config-computer/"
+ },
+ "cn:config/config-guide": {
+ "latest": "cn/docs/config/config-guide/",
+ "1.7": "cn/docs/config/config-guide/",
+ "1.5": "cn/docs/config/config-guide/",
+ "1.3": "cn/docs/config/config-guide/",
+ "1.0": "cn/docs/config/config-guide/"
+ },
+ "cn:config/config-https": {
+ "latest": "cn/docs/config/config-https/",
+ "1.7": "cn/docs/config/config-https/",
+ "1.5": "cn/docs/config/config-https/",
+ "1.3": "cn/docs/config/config-https/",
+ "1.0": "cn/docs/config/config-https/"
+ },
+ "cn:config/config-option": {
+ "latest": "cn/docs/config/config-option/",
+ "1.7": "cn/docs/config/config-option/",
+ "1.5": "cn/docs/config/config-option/",
+ "1.3": "cn/docs/config/config-option/",
+ "1.0": "cn/docs/config/config-option/"
+ },
+ "cn:contribution-guidelines": {
+ "latest": "cn/docs/contribution-guidelines/",
+ "1.7": "cn/docs/contribution-guidelines/",
+ "1.5": "cn/docs/contribution-guidelines/",
+ "1.3": "cn/docs/contribution-guidelines/",
+ "1.0": null
+ },
+ "cn:contribution-guidelines/committer-guidelines": {
+ "latest": "cn/docs/contribution-guidelines/committer-guidelines/",
+ "1.7": "cn/docs/contribution-guidelines/committer-guidelines/",
+ "1.5": "cn/docs/contribution-guidelines/committer-guidelines/",
+ "1.3": "cn/docs/contribution-guidelines/committer-guidelines/",
+ "1.0": null
+ },
+ "cn:contribution-guidelines/contribute": {
+ "latest": "cn/docs/contribution-guidelines/contribute/",
+ "1.7": "cn/docs/contribution-guidelines/contribute/",
+ "1.5": "cn/docs/contribution-guidelines/contribute/",
+ "1.3": "cn/docs/contribution-guidelines/contribute/",
+ "1.0": null
+ },
+ "cn:contribution-guidelines/hugegraph-server-idea-setup": {
+ "latest": "cn/docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.7": "cn/docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.5": "cn/docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.3": "cn/docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.0": null
+ },
+ "cn:contribution-guidelines/subscribe": {
+ "latest": "cn/docs/contribution-guidelines/subscribe/",
+ "1.7": "cn/docs/contribution-guidelines/subscribe/",
+ "1.5": "cn/docs/contribution-guidelines/subscribe/",
+ "1.3": "cn/docs/contribution-guidelines/subscribe/",
+ "1.0": null
+ },
+ "cn:contribution-guidelines/validate-release": {
+ "latest": "cn/docs/contribution-guidelines/validate-release/",
+ "1.7": "cn/docs/contribution-guidelines/validate-release/",
+ "1.5": "cn/docs/contribution-guidelines/validate-release/",
+ "1.3": "cn/docs/contribution-guidelines/validate-release/",
+ "1.0": null
+ },
+ "cn:download/download": {
+ "latest": "cn/docs/download/download/",
+ "1.7": "cn/docs/download/download/",
+ "1.5": "cn/docs/download/download/",
+ "1.3": "cn/docs/download/download/",
+ "1.0": "cn/docs/download/download/"
+ },
+ "cn:guides": {
+ "latest": "cn/docs/guides/",
+ "1.7": "cn/docs/guides/",
+ "1.5": "cn/docs/guides/",
+ "1.3": "cn/docs/guides/",
+ "1.0": "cn/docs/guides/"
+ },
+ "cn:guides/architectural": {
+ "latest": "cn/docs/guides/architectural/",
+ "1.7": "cn/docs/guides/architectural/",
+ "1.5": "cn/docs/guides/architectural/",
+ "1.3": "cn/docs/guides/architectural/",
+ "1.0": "cn/docs/guides/architectural/"
+ },
+ "cn:guides/backup-restore": {
+ "latest": "cn/docs/guides/backup-restore/",
+ "1.7": "cn/docs/guides/backup-restore/",
+ "1.5": "cn/docs/guides/backup-restore/",
+ "1.3": "cn/docs/guides/backup-restore/",
+ "1.0": "cn/docs/guides/backup-restore/"
+ },
+ "cn:guides/custom-plugin": {
+ "latest": "cn/docs/guides/custom-plugin/",
+ "1.7": "cn/docs/guides/custom-plugin/",
+ "1.5": "cn/docs/guides/custom-plugin/",
+ "1.3": "cn/docs/guides/custom-plugin/",
+ "1.0": "cn/docs/guides/custom-plugin/"
+ },
+ "cn:guides/desgin-concept": {
+ "latest": "cn/docs/guides/desgin-concept/",
+ "1.7": "cn/docs/guides/desgin-concept/",
+ "1.5": "cn/docs/guides/desgin-concept/",
+ "1.3": "cn/docs/guides/desgin-concept/",
+ "1.0": "cn/docs/guides/desgin-concept/"
+ },
+ "cn:guides/faq": {
+ "latest": "cn/docs/guides/faq/",
+ "1.7": "cn/docs/guides/faq/",
+ "1.5": "cn/docs/guides/faq/",
+ "1.3": "cn/docs/guides/faq/",
+ "1.0": "cn/docs/guides/faq/"
+ },
+ "cn:guides/hugegraph-docker-cluster": {
+ "latest": "cn/docs/guides/hugegraph-docker-cluster/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:guides/security": {
+ "latest": "cn/docs/guides/security/",
+ "1.7": "cn/docs/guides/security/",
+ "1.5": "cn/docs/guides/security/",
+ "1.3": "cn/docs/guides/security/",
+ "1.0": null
+ },
+ "cn:guides/toolchain-local-test": {
+ "latest": "cn/docs/guides/toolchain-local-test/",
+ "1.7": "cn/docs/guides/toolchain-local-test/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:introduction": {
+ "latest": "cn/docs/introduction/",
+ "1.7": "cn/docs/introduction/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:introduction/readme": {
+ "latest": null,
+ "1.7": "cn/docs/introduction/readme/",
+ "1.5": "cn/docs/introduction/readme/",
+ "1.3": "cn/docs/introduction/readme/",
+ "1.0": "cn/docs/introduction/readme/"
+ },
+ "cn:language": {
+ "latest": "cn/docs/language/",
+ "1.7": "cn/docs/language/",
+ "1.5": "cn/docs/language/",
+ "1.3": "cn/docs/language/",
+ "1.0": "cn/docs/language/"
+ },
+ "cn:language/hugegraph-example": {
+ "latest": "cn/docs/language/hugegraph-example/",
+ "1.7": "cn/docs/language/hugegraph-example/",
+ "1.5": "cn/docs/language/hugegraph-example/",
+ "1.3": "cn/docs/language/hugegraph-example/",
+ "1.0": "cn/docs/language/hugegraph-example/"
+ },
+ "cn:language/hugegraph-gremlin": {
+ "latest": "cn/docs/language/hugegraph-gremlin/",
+ "1.7": "cn/docs/language/hugegraph-gremlin/",
+ "1.5": "cn/docs/language/hugegraph-gremlin/",
+ "1.3": "cn/docs/language/hugegraph-gremlin/",
+ "1.0": "cn/docs/language/hugegraph-gremlin/"
+ },
+ "cn:performance": {
+ "latest": "cn/docs/performance/",
+ "1.7": "cn/docs/performance/",
+ "1.5": "cn/docs/performance/",
+ "1.3": "cn/docs/performance/",
+ "1.0": "cn/docs/performance/"
+ },
+ "cn:performance/api-performance": {
+ "latest": "cn/docs/performance/api-performance/",
+ "1.7": "cn/docs/performance/api-preformance/",
+ "1.5": "cn/docs/performance/api-preformance/",
+ "1.3": "cn/docs/performance/api-preformance/",
+ "1.0": "cn/docs/performance/api-preformance/"
+ },
+ "cn:performance/api-performance/hugegraph-api-0.2": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/performance/api-preformance/hugegraph-api-0.2/"
+ },
+ "cn:performance/api-performance/hugegraph-api-0.4.4": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "cn/docs/performance/api-preformance/hugegraph-api-0.4.4/"
+ },
+ "cn:performance/api-performance/hugegraph-api-0.5.6-cassandra": {
+ "latest": "cn/docs/performance/api-performance/hugegraph-api-0.5.6-cassandra/",
+ "1.7": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/",
+ "1.5": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/",
+ "1.3": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/",
+ "1.0": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/"
+ },
+ "cn:performance/api-performance/hugegraph-api-0.5.6-rocksdb": {
+ "latest": "cn/docs/performance/api-performance/hugegraph-api-0.5.6-rocksdb/",
+ "1.7": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/",
+ "1.5": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/",
+ "1.3": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/",
+ "1.0": "cn/docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/"
+ },
+ "cn:performance/hugegraph-benchmark-0.4.4": {
+ "latest": "cn/docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.7": "cn/docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.5": "cn/docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.3": "cn/docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.0": "cn/docs/performance/hugegraph-benchmark-0.4.4/"
+ },
+ "cn:performance/hugegraph-benchmark-0.5.6": {
+ "latest": "cn/docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.7": "cn/docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.5": "cn/docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.3": "cn/docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.0": "cn/docs/performance/hugegraph-benchmark-0.5.6/"
+ },
+ "cn:performance/hugegraph-loader-performance": {
+ "latest": "cn/docs/performance/hugegraph-loader-performance/",
+ "1.7": "cn/docs/performance/hugegraph-loader-performance/",
+ "1.5": "cn/docs/performance/hugegraph-loader-performance/",
+ "1.3": "cn/docs/performance/hugegraph-loader-performance/",
+ "1.0": "cn/docs/performance/hugegraph-loader-performance/"
+ },
+ "cn:quickstart": {
+ "latest": "cn/docs/quickstart/",
+ "1.7": "cn/docs/quickstart/",
+ "1.5": "cn/docs/quickstart/",
+ "1.3": "cn/docs/quickstart/",
+ "1.0": "cn/docs/quickstart/"
+ },
+ "cn:quickstart/client": {
+ "latest": "cn/docs/quickstart/client/",
+ "1.7": "cn/docs/quickstart/client/",
+ "1.5": "cn/docs/quickstart/client/",
+ "1.3": "cn/docs/quickstart/client/",
+ "1.0": "cn/docs/quickstart/client/"
+ },
+ "cn:quickstart/client/hugegraph-client": {
+ "latest": "cn/docs/quickstart/client/hugegraph-client/",
+ "1.7": "cn/docs/quickstart/client/hugegraph-client/",
+ "1.5": "cn/docs/quickstart/client/hugegraph-client/",
+ "1.3": "cn/docs/quickstart/client/hugegraph-client/",
+ "1.0": "cn/docs/quickstart/client/hugegraph-client/"
+ },
+ "cn:quickstart/client/hugegraph-client-go": {
+ "latest": "cn/docs/quickstart/client/hugegraph-client-go/",
+ "1.7": "cn/docs/quickstart/client/hugegraph-client-go/",
+ "1.5": "cn/docs/quickstart/client/hugegraph-client-go/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/client/hugegraph-client-python": {
+ "latest": "cn/docs/quickstart/client/hugegraph-client-python/",
+ "1.7": "cn/docs/quickstart/client/hugegraph-client-python/",
+ "1.5": "cn/docs/quickstart/client/hugegraph-client-python/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/computing": {
+ "latest": "cn/docs/quickstart/computing/",
+ "1.7": "cn/docs/quickstart/computing/",
+ "1.5": "cn/docs/quickstart/computing/",
+ "1.3": "cn/docs/quickstart/computing/",
+ "1.0": "cn/docs/quickstart/computing/"
+ },
+ "cn:quickstart/computing/hugegraph-computer": {
+ "latest": "cn/docs/quickstart/computing/hugegraph-computer/",
+ "1.7": "cn/docs/quickstart/computing/hugegraph-computer/",
+ "1.5": "cn/docs/quickstart/computing/hugegraph-computer/",
+ "1.3": "cn/docs/quickstart/computing/hugegraph-computer/",
+ "1.0": "cn/docs/quickstart/computing/hugegraph-computer/"
+ },
+ "cn:quickstart/computing/hugegraph-computer-config": {
+ "latest": "cn/docs/quickstart/computing/hugegraph-computer-config/",
+ "1.7": "cn/docs/quickstart/computing/hugegraph-computer-config/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/computing/hugegraph-vermeer": {
+ "latest": "cn/docs/quickstart/computing/hugegraph-vermeer/",
+ "1.7": "cn/docs/quickstart/computing/hugegraph-vermeer/",
+ "1.5": "cn/docs/quickstart/computing/hugegraph-vermeer/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph": {
+ "latest": "cn/docs/quickstart/hugegraph/",
+ "1.7": "cn/docs/quickstart/hugegraph/",
+ "1.5": "cn/docs/quickstart/hugegraph/",
+ "1.3": "cn/docs/quickstart/hugegraph/",
+ "1.0": "cn/docs/quickstart/hugegraph/"
+ },
+ "cn:quickstart/hugegraph-ai": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/",
+ "1.7": "cn/docs/quickstart/hugegraph-ai/",
+ "1.5": "cn/docs/quickstart/hugegraph-ai/",
+ "1.3": "cn/docs/quickstart/hugegraph-ai/",
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph-ai/config-reference": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/config-reference/",
+ "1.7": "cn/docs/quickstart/hugegraph-ai/config-reference/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph-ai/hugegraph-llm": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/hugegraph-llm/",
+ "1.7": "cn/docs/quickstart/hugegraph-ai/hugegraph-llm/",
+ "1.5": "cn/docs/quickstart/hugegraph-ai/hugegraph-llm/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph-ai/hugegraph-ml": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/hugegraph-ml/",
+ "1.7": "cn/docs/quickstart/hugegraph-ai/hugegraph-ml/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph-ai/quick_start": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/quick_start/",
+ "1.7": "cn/docs/quickstart/hugegraph-ai/quick_start/",
+ "1.5": "cn/docs/quickstart/hugegraph-ai/quick_start/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph-ai/rest-api": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/rest-api/",
+ "1.7": "cn/docs/quickstart/hugegraph-ai/rest-api/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph-ai/vermeer-python-client": {
+ "latest": "cn/docs/quickstart/hugegraph-ai/vermeer-python-client/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph/hugegraph-hstore": {
+ "latest": "cn/docs/quickstart/hugegraph/hugegraph-hstore/",
+ "1.7": "cn/docs/quickstart/hugegraph/hugegraph-hstore/",
+ "1.5": "cn/docs/quickstart/hugegraph/hugegraph-hstore/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph/hugegraph-pd": {
+ "latest": "cn/docs/quickstart/hugegraph/hugegraph-pd/",
+ "1.7": "cn/docs/quickstart/hugegraph/hugegraph-pd/",
+ "1.5": "cn/docs/quickstart/hugegraph/hugegraph-pd/",
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/hugegraph/hugegraph-server": {
+ "latest": "cn/docs/quickstart/hugegraph/hugegraph-server/",
+ "1.7": "cn/docs/quickstart/hugegraph/hugegraph-server/",
+ "1.5": "cn/docs/quickstart/hugegraph/hugegraph-server/",
+ "1.3": "cn/docs/quickstart/hugegraph/hugegraph-server/",
+ "1.0": "cn/docs/quickstart/hugegraph/hugegraph-server/"
+ },
+ "cn:quickstart/toolchain": {
+ "latest": "cn/docs/quickstart/toolchain/",
+ "1.7": "cn/docs/quickstart/toolchain/",
+ "1.5": "cn/docs/quickstart/toolchain/",
+ "1.3": "cn/docs/quickstart/toolchain/",
+ "1.0": "cn/docs/quickstart/toolchain/"
+ },
+ "cn:quickstart/toolchain/export-migration": {
+ "latest": "cn/docs/quickstart/toolchain/export-migration/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/toolchain/export-migration/hugegraph-seatunnel-source": {
+ "latest": "cn/docs/quickstart/toolchain/export-migration/hugegraph-seatunnel-source/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/toolchain/hugegraph-hubble": {
+ "latest": "cn/docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.7": "cn/docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.5": "cn/docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.3": "cn/docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.0": "cn/docs/quickstart/toolchain/hugegraph-hubble/"
+ },
+ "cn:quickstart/toolchain/hugegraph-loader": {
+ "latest": "cn/docs/quickstart/toolchain/hugegraph-loader/",
+ "1.7": "cn/docs/quickstart/toolchain/hugegraph-loader/",
+ "1.5": "cn/docs/quickstart/toolchain/hugegraph-loader/",
+ "1.3": "cn/docs/quickstart/toolchain/hugegraph-loader/",
+ "1.0": "cn/docs/quickstart/toolchain/hugegraph-loader/"
+ },
+ "cn:quickstart/toolchain/hugegraph-spark-connector": {
+ "latest": "cn/docs/quickstart/toolchain/hugegraph-spark-connector/",
+ "1.7": "cn/docs/quickstart/toolchain/hugegraph-spark-connector/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/toolchain/hugegraph-tools": {
+ "latest": "cn/docs/quickstart/toolchain/hugegraph-tools/",
+ "1.7": "cn/docs/quickstart/toolchain/hugegraph-tools/",
+ "1.5": "cn/docs/quickstart/toolchain/hugegraph-tools/",
+ "1.3": "cn/docs/quickstart/toolchain/hugegraph-tools/",
+ "1.0": "cn/docs/quickstart/toolchain/hugegraph-tools/"
+ },
+ "cn:quickstart/toolchain/import": {
+ "latest": "cn/docs/quickstart/toolchain/import/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/toolchain/import/hugegraph-seatunnel-connector": {
+ "latest": "cn/docs/quickstart/toolchain/import/hugegraph-seatunnel-connector/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "cn:quickstart/toolchain/visualization": {
+ "latest": "cn/docs/quickstart/toolchain/visualization/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:": {
+ "latest": "docs/",
+ "1.7": "docs/",
+ "1.5": "docs/",
+ "1.3": "docs/",
+ "1.0": "docs/"
+ },
+ "en:changelog": {
+ "latest": "docs/changelog/",
+ "1.7": "docs/changelog/",
+ "1.5": "docs/changelog/",
+ "1.3": "docs/changelog/",
+ "1.0": "docs/changelog/"
+ },
+ "en:changelog/hugegraph-0.12.0-release-notes": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "docs/changelog/hugegraph-0.12.0-release-notes/"
+ },
+ "en:changelog/hugegraph-1.0.0-release-notes": {
+ "latest": "docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.7": "docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.5": "docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.3": "docs/changelog/hugegraph-1.0.0-release-notes/",
+ "1.0": "docs/changelog/hugegraph-1.0.0-release-notes/"
+ },
+ "en:changelog/hugegraph-1.2.0-release-notes": {
+ "latest": "docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.7": "docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.5": "docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.3": "docs/changelog/hugegraph-1.2.0-release-notes/",
+ "1.0": null
+ },
+ "en:changelog/hugegraph-1.3.0-release-notes": {
+ "latest": "docs/changelog/hugegraph-1.3.0-release-notes/",
+ "1.7": "docs/changelog/hugegraph-1.3.0-release-notes/",
+ "1.5": "docs/changelog/hugegraph-1.3.0-release-notes/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:changelog/hugegraph-1.5.0-release-notes": {
+ "latest": "docs/changelog/hugegraph-1.5.0-release-notes/",
+ "1.7": "docs/changelog/hugegraph-1.5.0-release-notes/",
+ "1.5": "docs/changelog/hugegraph-1.5.0-release-notes/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:changelog/hugegraph-1.7.0-release-notes": {
+ "latest": "docs/changelog/hugegraph-1.7.0-release-notes/",
+ "1.7": "docs/changelog/hugegraph-1.7.0-release-notes/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:cla": {
+ "latest": "docs/cla/",
+ "1.7": "docs/cla/",
+ "1.5": "docs/cla/",
+ "1.3": "docs/cla/",
+ "1.0": "docs/cla/"
+ },
+ "en:clients": {
+ "latest": "docs/clients/",
+ "1.7": "docs/clients/",
+ "1.5": "docs/clients/",
+ "1.3": "docs/clients/",
+ "1.0": "docs/clients/"
+ },
+ "en:clients/gremlin-console": {
+ "latest": "docs/clients/gremlin-console/",
+ "1.7": "docs/clients/gremlin-console/",
+ "1.5": "docs/clients/gremlin-console/",
+ "1.3": "docs/clients/gremlin-console/",
+ "1.0": "docs/clients/gremlin-console/"
+ },
+ "en:clients/hugegraph-client": {
+ "latest": "docs/clients/hugegraph-client/",
+ "1.7": "docs/clients/hugegraph-client/",
+ "1.5": "docs/clients/hugegraph-client/",
+ "1.3": "docs/clients/hugegraph-client/",
+ "1.0": "docs/clients/hugegraph-client/"
+ },
+ "en:clients/restful-api": {
+ "latest": "docs/clients/restful-api/",
+ "1.7": "docs/clients/restful-api/",
+ "1.5": "docs/clients/restful-api/",
+ "1.3": "docs/clients/restful-api/",
+ "1.0": "docs/clients/restful-api/"
+ },
+ "en:clients/restful-api/auth": {
+ "latest": "docs/clients/restful-api/auth/",
+ "1.7": "docs/clients/restful-api/auth/",
+ "1.5": "docs/clients/restful-api/auth/",
+ "1.3": "docs/clients/restful-api/auth/",
+ "1.0": "docs/clients/restful-api/auth/"
+ },
+ "en:clients/restful-api/cypher": {
+ "latest": "docs/clients/restful-api/cypher/",
+ "1.7": "docs/clients/restful-api/cypher/",
+ "1.5": "docs/clients/restful-api/cypher/",
+ "1.3": "docs/clients/restful-api/cypher/",
+ "1.0": null
+ },
+ "en:clients/restful-api/edge": {
+ "latest": "docs/clients/restful-api/edge/",
+ "1.7": "docs/clients/restful-api/edge/",
+ "1.5": "docs/clients/restful-api/edge/",
+ "1.3": "docs/clients/restful-api/edge/",
+ "1.0": "docs/clients/restful-api/edge/"
+ },
+ "en:clients/restful-api/edgelabel": {
+ "latest": "docs/clients/restful-api/edgelabel/",
+ "1.7": "docs/clients/restful-api/edgelabel/",
+ "1.5": "docs/clients/restful-api/edgelabel/",
+ "1.3": "docs/clients/restful-api/edgelabel/",
+ "1.0": "docs/clients/restful-api/edgelabel/"
+ },
+ "en:clients/restful-api/graphs": {
+ "latest": "docs/clients/restful-api/graphs/",
+ "1.7": "docs/clients/restful-api/graphs/",
+ "1.5": "docs/clients/restful-api/graphs/",
+ "1.3": "docs/clients/restful-api/graphs/",
+ "1.0": "docs/clients/restful-api/graphs/"
+ },
+ "en:clients/restful-api/graphspace": {
+ "latest": "docs/clients/restful-api/graphspace/",
+ "1.7": "docs/clients/restful-api/graphspace/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:clients/restful-api/gremlin": {
+ "latest": "docs/clients/restful-api/gremlin/",
+ "1.7": "docs/clients/restful-api/gremlin/",
+ "1.5": "docs/clients/restful-api/gremlin/",
+ "1.3": "docs/clients/restful-api/gremlin/",
+ "1.0": "docs/clients/restful-api/gremlin/"
+ },
+ "en:clients/restful-api/indexlabel": {
+ "latest": "docs/clients/restful-api/indexlabel/",
+ "1.7": "docs/clients/restful-api/indexlabel/",
+ "1.5": "docs/clients/restful-api/indexlabel/",
+ "1.3": "docs/clients/restful-api/indexlabel/",
+ "1.0": "docs/clients/restful-api/indexlabel/"
+ },
+ "en:clients/restful-api/metrics": {
+ "latest": "docs/clients/restful-api/metrics/",
+ "1.7": "docs/clients/restful-api/metrics/",
+ "1.5": "docs/clients/restful-api/metrics/",
+ "1.3": "docs/clients/restful-api/metrics/",
+ "1.0": null
+ },
+ "en:clients/restful-api/other": {
+ "latest": "docs/clients/restful-api/other/",
+ "1.7": "docs/clients/restful-api/other/",
+ "1.5": "docs/clients/restful-api/other/",
+ "1.3": "docs/clients/restful-api/other/",
+ "1.0": "docs/clients/restful-api/other/"
+ },
+ "en:clients/restful-api/propertykey": {
+ "latest": "docs/clients/restful-api/propertykey/",
+ "1.7": "docs/clients/restful-api/propertykey/",
+ "1.5": "docs/clients/restful-api/propertykey/",
+ "1.3": "docs/clients/restful-api/propertykey/",
+ "1.0": "docs/clients/restful-api/propertykey/"
+ },
+ "en:clients/restful-api/rank": {
+ "latest": "docs/clients/restful-api/rank/",
+ "1.7": "docs/clients/restful-api/rank/",
+ "1.5": "docs/clients/restful-api/rank/",
+ "1.3": "docs/clients/restful-api/rank/",
+ "1.0": "docs/clients/restful-api/rank/"
+ },
+ "en:clients/restful-api/rebuild": {
+ "latest": "docs/clients/restful-api/rebuild/",
+ "1.7": "docs/clients/restful-api/rebuild/",
+ "1.5": "docs/clients/restful-api/rebuild/",
+ "1.3": "docs/clients/restful-api/rebuild/",
+ "1.0": "docs/clients/restful-api/rebuild/"
+ },
+ "en:clients/restful-api/schema": {
+ "latest": "docs/clients/restful-api/schema/",
+ "1.7": "docs/clients/restful-api/schema/",
+ "1.5": "docs/clients/restful-api/schema/",
+ "1.3": "docs/clients/restful-api/schema/",
+ "1.0": "docs/clients/restful-api/schema/"
+ },
+ "en:clients/restful-api/task": {
+ "latest": "docs/clients/restful-api/task/",
+ "1.7": "docs/clients/restful-api/task/",
+ "1.5": "docs/clients/restful-api/task/",
+ "1.3": "docs/clients/restful-api/task/",
+ "1.0": "docs/clients/restful-api/task/"
+ },
+ "en:clients/restful-api/traverser": {
+ "latest": "docs/clients/restful-api/traverser/",
+ "1.7": "docs/clients/restful-api/traverser/",
+ "1.5": "docs/clients/restful-api/traverser/",
+ "1.3": "docs/clients/restful-api/traverser/",
+ "1.0": "docs/clients/restful-api/traverser/"
+ },
+ "en:clients/restful-api/variable": {
+ "latest": "docs/clients/restful-api/variable/",
+ "1.7": "docs/clients/restful-api/variable/",
+ "1.5": "docs/clients/restful-api/variable/",
+ "1.3": "docs/clients/restful-api/variable/",
+ "1.0": "docs/clients/restful-api/variable/"
+ },
+ "en:clients/restful-api/vertex": {
+ "latest": "docs/clients/restful-api/vertex/",
+ "1.7": "docs/clients/restful-api/vertex/",
+ "1.5": "docs/clients/restful-api/vertex/",
+ "1.3": "docs/clients/restful-api/vertex/",
+ "1.0": "docs/clients/restful-api/vertex/"
+ },
+ "en:clients/restful-api/vertexlabel": {
+ "latest": "docs/clients/restful-api/vertexlabel/",
+ "1.7": "docs/clients/restful-api/vertexlabel/",
+ "1.5": "docs/clients/restful-api/vertexlabel/",
+ "1.3": "docs/clients/restful-api/vertexlabel/",
+ "1.0": "docs/clients/restful-api/vertexlabel/"
+ },
+ "en:config": {
+ "latest": "docs/config/",
+ "1.7": "docs/config/",
+ "1.5": "docs/config/",
+ "1.3": "docs/config/",
+ "1.0": "docs/config/"
+ },
+ "en:config/config-authentication": {
+ "latest": "docs/config/config-authentication/",
+ "1.7": "docs/config/config-authentication/",
+ "1.5": "docs/config/config-authentication/",
+ "1.3": "docs/config/config-authentication/",
+ "1.0": "docs/config/config-authentication/"
+ },
+ "en:config/config-backend-hbase": {
+ "latest": "docs/config/config-backend-hbase/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:config/config-backend-hstore": {
+ "latest": "docs/config/config-backend-hstore/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:config/config-backend-rocksdb": {
+ "latest": "docs/config/config-backend-rocksdb/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:config/config-computer": {
+ "latest": null,
+ "1.7": "docs/config/config-computer/",
+ "1.5": "docs/config/config-computer/",
+ "1.3": "docs/config/config-computer/",
+ "1.0": "docs/config/config-computer/"
+ },
+ "en:config/config-guide": {
+ "latest": "docs/config/config-guide/",
+ "1.7": "docs/config/config-guide/",
+ "1.5": "docs/config/config-guide/",
+ "1.3": "docs/config/config-guide/",
+ "1.0": "docs/config/config-guide/"
+ },
+ "en:config/config-https": {
+ "latest": "docs/config/config-https/",
+ "1.7": "docs/config/config-https/",
+ "1.5": "docs/config/config-https/",
+ "1.3": "docs/config/config-https/",
+ "1.0": "docs/config/config-https/"
+ },
+ "en:config/config-option": {
+ "latest": "docs/config/config-option/",
+ "1.7": "docs/config/config-option/",
+ "1.5": "docs/config/config-option/",
+ "1.3": "docs/config/config-option/",
+ "1.0": "docs/config/config-option/"
+ },
+ "en:contribution-guidelines": {
+ "latest": "docs/contribution-guidelines/",
+ "1.7": "docs/contribution-guidelines/",
+ "1.5": "docs/contribution-guidelines/",
+ "1.3": "docs/contribution-guidelines/",
+ "1.0": "docs/contribution-guidelines/"
+ },
+ "en:contribution-guidelines/committer-guidelines": {
+ "latest": "docs/contribution-guidelines/committer-guidelines/",
+ "1.7": "docs/contribution-guidelines/committer-guidelines/",
+ "1.5": "docs/contribution-guidelines/committer-guidelines/",
+ "1.3": "docs/contribution-guidelines/committer-guidelines/",
+ "1.0": null
+ },
+ "en:contribution-guidelines/contribute": {
+ "latest": "docs/contribution-guidelines/contribute/",
+ "1.7": "docs/contribution-guidelines/contribute/",
+ "1.5": "docs/contribution-guidelines/contribute/",
+ "1.3": "docs/contribution-guidelines/contribute/",
+ "1.0": "docs/contribution-guidelines/contribute/"
+ },
+ "en:contribution-guidelines/hugegraph-server-idea-setup": {
+ "latest": "docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.7": "docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.5": "docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.3": "docs/contribution-guidelines/hugegraph-server-idea-setup/",
+ "1.0": null
+ },
+ "en:contribution-guidelines/subscribe": {
+ "latest": "docs/contribution-guidelines/subscribe/",
+ "1.7": "docs/contribution-guidelines/subscribe/",
+ "1.5": "docs/contribution-guidelines/subscribe/",
+ "1.3": "docs/contribution-guidelines/subscribe/",
+ "1.0": "docs/contribution-guidelines/subscribe/"
+ },
+ "en:contribution-guidelines/validate-release": {
+ "latest": "docs/contribution-guidelines/validate-release/",
+ "1.7": "docs/contribution-guidelines/validate-release/",
+ "1.5": "docs/contribution-guidelines/validate-release/",
+ "1.3": "docs/contribution-guidelines/validate-release/",
+ "1.0": "docs/contribution-guidelines/validate-release/"
+ },
+ "en:download/download": {
+ "latest": "docs/download/download/",
+ "1.7": "docs/download/download/",
+ "1.5": "docs/download/download/",
+ "1.3": "docs/download/download/",
+ "1.0": "docs/download/download/"
+ },
+ "en:guides": {
+ "latest": "docs/guides/",
+ "1.7": "docs/guides/",
+ "1.5": "docs/guides/",
+ "1.3": "docs/guides/",
+ "1.0": "docs/guides/"
+ },
+ "en:guides/architectural": {
+ "latest": "docs/guides/architectural/",
+ "1.7": "docs/guides/architectural/",
+ "1.5": "docs/guides/architectural/",
+ "1.3": "docs/guides/architectural/",
+ "1.0": "docs/guides/architectural/"
+ },
+ "en:guides/backup-restore": {
+ "latest": "docs/guides/backup-restore/",
+ "1.7": "docs/guides/backup-restore/",
+ "1.5": "docs/guides/backup-restore/",
+ "1.3": "docs/guides/backup-restore/",
+ "1.0": "docs/guides/backup-restore/"
+ },
+ "en:guides/custom-plugin": {
+ "latest": "docs/guides/custom-plugin/",
+ "1.7": "docs/guides/custom-plugin/",
+ "1.5": "docs/guides/custom-plugin/",
+ "1.3": "docs/guides/custom-plugin/",
+ "1.0": "docs/guides/custom-plugin/"
+ },
+ "en:guides/desgin-concept": {
+ "latest": "docs/guides/desgin-concept/",
+ "1.7": "docs/guides/desgin-concept/",
+ "1.5": "docs/guides/desgin-concept/",
+ "1.3": "docs/guides/desgin-concept/",
+ "1.0": "docs/guides/desgin-concept/"
+ },
+ "en:guides/faq": {
+ "latest": "docs/guides/faq/",
+ "1.7": "docs/guides/faq/",
+ "1.5": "docs/guides/faq/",
+ "1.3": "docs/guides/faq/",
+ "1.0": "docs/guides/faq/"
+ },
+ "en:guides/hugegraph-docker-cluster": {
+ "latest": "docs/guides/hugegraph-docker-cluster/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:guides/security": {
+ "latest": "docs/guides/security/",
+ "1.7": "docs/guides/security/",
+ "1.5": "docs/guides/security/",
+ "1.3": "docs/guides/security/",
+ "1.0": null
+ },
+ "en:guides/toolchain-local-test": {
+ "latest": "docs/guides/toolchain-local-test/",
+ "1.7": "docs/guides/toolchain-local-test/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:introduction": {
+ "latest": "docs/introduction/",
+ "1.7": "docs/introduction/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:introduction/readme": {
+ "latest": null,
+ "1.7": "docs/introduction/readme/",
+ "1.5": "docs/introduction/readme/",
+ "1.3": "docs/introduction/readme/",
+ "1.0": "docs/introduction/readme/"
+ },
+ "en:language": {
+ "latest": "docs/language/",
+ "1.7": "docs/language/",
+ "1.5": "docs/language/",
+ "1.3": "docs/language/",
+ "1.0": "docs/language/"
+ },
+ "en:language/hugegraph-example": {
+ "latest": "docs/language/hugegraph-example/",
+ "1.7": "docs/language/hugegraph-example/",
+ "1.5": "docs/language/hugegraph-example/",
+ "1.3": "docs/language/hugegraph-example/",
+ "1.0": "docs/language/hugegraph-example/"
+ },
+ "en:language/hugegraph-gremlin": {
+ "latest": "docs/language/hugegraph-gremlin/",
+ "1.7": "docs/language/hugegraph-gremlin/",
+ "1.5": "docs/language/hugegraph-gremlin/",
+ "1.3": "docs/language/hugegraph-gremlin/",
+ "1.0": "docs/language/hugegraph-gremlin/"
+ },
+ "en:performance": {
+ "latest": "docs/performance/",
+ "1.7": "docs/performance/",
+ "1.5": "docs/performance/",
+ "1.3": "docs/performance/",
+ "1.0": "docs/performance/"
+ },
+ "en:performance/api-performance": {
+ "latest": "docs/performance/api-performance/",
+ "1.7": "docs/performance/api-preformance/",
+ "1.5": "docs/performance/api-preformance/",
+ "1.3": "docs/performance/api-preformance/",
+ "1.0": "docs/performance/api-preformance/"
+ },
+ "en:performance/api-performance/hugegraph-api-0.2": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "docs/performance/api-preformance/hugegraph-api-0.2/"
+ },
+ "en:performance/api-performance/hugegraph-api-0.4.4": {
+ "latest": null,
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": "docs/performance/api-preformance/hugegraph-api-0.4.4/"
+ },
+ "en:performance/api-performance/hugegraph-api-0.5.6-cassandra": {
+ "latest": "docs/performance/api-performance/hugegraph-api-0.5.6-cassandra/",
+ "1.7": "docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/",
+ "1.5": "docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/",
+ "1.3": "docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/",
+ "1.0": "docs/performance/api-preformance/hugegraph-api-0.5.6-cassandra/"
+ },
+ "en:performance/api-performance/hugegraph-api-0.5.6-rocksdb": {
+ "latest": "docs/performance/api-performance/hugegraph-api-0.5.6-rocksdb/",
+ "1.7": "docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/",
+ "1.5": "docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/",
+ "1.3": "docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/",
+ "1.0": "docs/performance/api-preformance/hugegraph-api-0.5.6-rocksdb/"
+ },
+ "en:performance/hugegraph-benchmark-0.4.4": {
+ "latest": "docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.7": "docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.5": "docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.3": "docs/performance/hugegraph-benchmark-0.4.4/",
+ "1.0": "docs/performance/hugegraph-benchmark-0.4.4/"
+ },
+ "en:performance/hugegraph-benchmark-0.5.6": {
+ "latest": "docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.7": "docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.5": "docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.3": "docs/performance/hugegraph-benchmark-0.5.6/",
+ "1.0": "docs/performance/hugegraph-benchmark-0.5.6/"
+ },
+ "en:performance/hugegraph-loader-performance": {
+ "latest": "docs/performance/hugegraph-loader-performance/",
+ "1.7": "docs/performance/hugegraph-loader-performance/",
+ "1.5": "docs/performance/hugegraph-loader-performance/",
+ "1.3": "docs/performance/hugegraph-loader-performance/",
+ "1.0": "docs/performance/hugegraph-loader-performance/"
+ },
+ "en:quickstart": {
+ "latest": "docs/quickstart/",
+ "1.7": "docs/quickstart/",
+ "1.5": "docs/quickstart/",
+ "1.3": "docs/quickstart/",
+ "1.0": "docs/quickstart/"
+ },
+ "en:quickstart/client": {
+ "latest": "docs/quickstart/client/",
+ "1.7": "docs/quickstart/client/",
+ "1.5": "docs/quickstart/client/",
+ "1.3": "docs/quickstart/client/",
+ "1.0": "docs/quickstart/client/"
+ },
+ "en:quickstart/client/hugegraph-client": {
+ "latest": "docs/quickstart/client/hugegraph-client/",
+ "1.7": "docs/quickstart/client/hugegraph-client/",
+ "1.5": "docs/quickstart/client/hugegraph-client/",
+ "1.3": "docs/quickstart/client/hugegraph-client/",
+ "1.0": "docs/quickstart/client/hugegraph-client/"
+ },
+ "en:quickstart/client/hugegraph-client-go": {
+ "latest": "docs/quickstart/client/hugegraph-client-go/",
+ "1.7": "docs/quickstart/client/hugegraph-client-go/",
+ "1.5": "docs/quickstart/client/hugegraph-client-go/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/client/hugegraph-client-python": {
+ "latest": "docs/quickstart/client/hugegraph-client-python/",
+ "1.7": "docs/quickstart/client/hugegraph-client-python/",
+ "1.5": "docs/quickstart/client/hugegraph-client-python/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/computing": {
+ "latest": "docs/quickstart/computing/",
+ "1.7": "docs/quickstart/computing/",
+ "1.5": "docs/quickstart/computing/",
+ "1.3": "docs/quickstart/computing/",
+ "1.0": "docs/quickstart/computing/"
+ },
+ "en:quickstart/computing/hugegraph-computer": {
+ "latest": "docs/quickstart/computing/hugegraph-computer/",
+ "1.7": "docs/quickstart/computing/hugegraph-computer/",
+ "1.5": "docs/quickstart/computing/hugegraph-computer/",
+ "1.3": "docs/quickstart/computing/hugegraph-computer/",
+ "1.0": "docs/quickstart/computing/hugegraph-computer/"
+ },
+ "en:quickstart/computing/hugegraph-computer-config": {
+ "latest": "docs/quickstart/computing/hugegraph-computer-config/",
+ "1.7": "docs/quickstart/computing/hugegraph-computer-config/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/computing/hugegraph-vermeer": {
+ "latest": "docs/quickstart/computing/hugegraph-vermeer/",
+ "1.7": "docs/quickstart/computing/hugegraph-vermeer/",
+ "1.5": "docs/quickstart/computing/hugegraph-vermeer/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph": {
+ "latest": "docs/quickstart/hugegraph/",
+ "1.7": "docs/quickstart/hugegraph/",
+ "1.5": "docs/quickstart/hugegraph/",
+ "1.3": "docs/quickstart/hugegraph/",
+ "1.0": "docs/quickstart/hugegraph/"
+ },
+ "en:quickstart/hugegraph-ai": {
+ "latest": "docs/quickstart/hugegraph-ai/",
+ "1.7": "docs/quickstart/hugegraph-ai/",
+ "1.5": "docs/quickstart/hugegraph-ai/",
+ "1.3": "docs/quickstart/hugegraph-ai/",
+ "1.0": null
+ },
+ "en:quickstart/hugegraph-ai/config-reference": {
+ "latest": "docs/quickstart/hugegraph-ai/config-reference/",
+ "1.7": "docs/quickstart/hugegraph-ai/config-reference/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph-ai/hugegraph-llm": {
+ "latest": "docs/quickstart/hugegraph-ai/hugegraph-llm/",
+ "1.7": "docs/quickstart/hugegraph-ai/hugegraph-llm/",
+ "1.5": "docs/quickstart/hugegraph-ai/hugegraph-llm/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph-ai/hugegraph-ml": {
+ "latest": "docs/quickstart/hugegraph-ai/hugegraph-ml/",
+ "1.7": "docs/quickstart/hugegraph-ai/hugegraph-ml/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph-ai/quick_start": {
+ "latest": "docs/quickstart/hugegraph-ai/quick_start/",
+ "1.7": "docs/quickstart/hugegraph-ai/quick_start/",
+ "1.5": "docs/quickstart/hugegraph-ai/quick_start/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph-ai/rest-api": {
+ "latest": "docs/quickstart/hugegraph-ai/rest-api/",
+ "1.7": "docs/quickstart/hugegraph-ai/rest-api/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph-ai/vermeer-python-client": {
+ "latest": "docs/quickstart/hugegraph-ai/vermeer-python-client/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph/hugegraph-hstore": {
+ "latest": "docs/quickstart/hugegraph/hugegraph-hstore/",
+ "1.7": "docs/quickstart/hugegraph/hugegraph-hstore/",
+ "1.5": "docs/quickstart/hugegraph/hugegraph-hstore/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph/hugegraph-pd": {
+ "latest": "docs/quickstart/hugegraph/hugegraph-pd/",
+ "1.7": "docs/quickstart/hugegraph/hugegraph-pd/",
+ "1.5": "docs/quickstart/hugegraph/hugegraph-pd/",
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/hugegraph/hugegraph-server": {
+ "latest": "docs/quickstart/hugegraph/hugegraph-server/",
+ "1.7": "docs/quickstart/hugegraph/hugegraph-server/",
+ "1.5": "docs/quickstart/hugegraph/hugegraph-server/",
+ "1.3": "docs/quickstart/hugegraph/hugegraph-server/",
+ "1.0": "docs/quickstart/hugegraph/hugegraph-server/"
+ },
+ "en:quickstart/toolchain": {
+ "latest": "docs/quickstart/toolchain/",
+ "1.7": "docs/quickstart/toolchain/",
+ "1.5": "docs/quickstart/toolchain/",
+ "1.3": "docs/quickstart/toolchain/",
+ "1.0": "docs/quickstart/toolchain/"
+ },
+ "en:quickstart/toolchain/export-migration": {
+ "latest": "docs/quickstart/toolchain/export-migration/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/toolchain/export-migration/hugegraph-seatunnel-source": {
+ "latest": "docs/quickstart/toolchain/export-migration/hugegraph-seatunnel-source/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/toolchain/hugegraph-hubble": {
+ "latest": "docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.7": "docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.5": "docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.3": "docs/quickstart/toolchain/hugegraph-hubble/",
+ "1.0": "docs/quickstart/toolchain/hugegraph-hubble/"
+ },
+ "en:quickstart/toolchain/hugegraph-loader": {
+ "latest": "docs/quickstart/toolchain/hugegraph-loader/",
+ "1.7": "docs/quickstart/toolchain/hugegraph-loader/",
+ "1.5": "docs/quickstart/toolchain/hugegraph-loader/",
+ "1.3": "docs/quickstart/toolchain/hugegraph-loader/",
+ "1.0": "docs/quickstart/toolchain/hugegraph-loader/"
+ },
+ "en:quickstart/toolchain/hugegraph-spark-connector": {
+ "latest": "docs/quickstart/toolchain/hugegraph-spark-connector/",
+ "1.7": "docs/quickstart/toolchain/hugegraph-spark-connector/",
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/toolchain/hugegraph-tools": {
+ "latest": "docs/quickstart/toolchain/hugegraph-tools/",
+ "1.7": "docs/quickstart/toolchain/hugegraph-tools/",
+ "1.5": "docs/quickstart/toolchain/hugegraph-tools/",
+ "1.3": "docs/quickstart/toolchain/hugegraph-tools/",
+ "1.0": "docs/quickstart/toolchain/hugegraph-tools/"
+ },
+ "en:quickstart/toolchain/import": {
+ "latest": "docs/quickstart/toolchain/import/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/toolchain/import/hugegraph-seatunnel-connector": {
+ "latest": "docs/quickstart/toolchain/import/hugegraph-seatunnel-connector/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ },
+ "en:quickstart/toolchain/visualization": {
+ "latest": "docs/quickstart/toolchain/visualization/",
+ "1.7": null,
+ "1.5": null,
+ "1.3": null,
+ "1.0": null
+ }
+ },
+ "equivalents": [
+ [
+ "cn:introduction",
+ "cn:introduction/readme"
+ ],
+ [
+ "en:introduction",
+ "en:introduction/readme"
+ ]
+ ]
+}
diff --git a/dist/validate-site-output.py b/dist/validate-site-output.py
index d9949f596..28a5b82e1 100644
--- a/dist/validate-site-output.py
+++ b/dist/validate-site-output.py
@@ -67,14 +67,10 @@ ASF_CSP_IMAGE_HOSTS = {
"www.communityovercode.org",
}
ASF_CSP_IMAGE_SUFFIXES = (".apache.org", ".scarf.sh")
-UNSAFE_AUTHORED_ELEMENTS = {"script", "iframe", "object", "embed"}
+UNSAFE_AUTHORED_ELEMENTS = {"script", "iframe", "frame", "object", "embed"}
ERROR_DOCUMENT_PATHS = {
"404.html",
"cn/404.html",
- "versions/1.7/404.html",
- "versions/1.7/cn/404.html",
- "versions/1.5/404.html",
- "versions/1.5/cn/404.html",
}
DOCS_NAV_GROUP_TITLES = {
"en": ("Get Started", "Components", "Develop", "Operate", "Reference"),
@@ -84,12 +80,85 @@ EXTERNAL_ACTIVE_RESOURCE_ATTRIBUTES = {
("script", "src"),
("link", "href"),
("iframe", "src"),
+ ("frame", "src"),
("object", "data"),
("embed", "src"),
("audio", "src"),
("video", "src"),
("track", "src"),
+ ("media-source", "src"),
+ ("media-source", "srcset"),
+ ("form", "action"),
+ ("button", "formaction"),
+ ("input", "formaction"),
+ ("use", "href"),
+ ("use", "xlink:href"),
+ ("mpath", "href"),
+ ("mpath", "xlink:href"),
+ ("textpath", "href"),
+ ("textpath", "xlink:href"),
+ ("tref", "href"),
+ ("tref", "xlink:href"),
+ ("cursor", "href"),
+ ("cursor", "xlink:href"),
+ ("animate", "href"),
+ ("animate", "xlink:href"),
+ ("animatemotion", "href"),
+ ("animatemotion", "xlink:href"),
+ ("animatetransform", "href"),
+ ("animatetransform", "xlink:href"),
+ ("set", "href"),
+ ("set", "xlink:href"),
+ ("script", "href"),
+ ("script", "xlink:href"),
+ ("link", "imagesrcset"),
}
+LINK_RESOURCE_RELS = {
+ "stylesheet",
+ "preload",
+ "modulepreload",
+ "icon",
+ "apple-touch-icon",
+ "mask-icon",
+ "apple-touch-startup-image",
+ "manifest",
+ "prefetch",
+ "prerender",
+ "preconnect",
+ "dns-prefetch",
+}
+LINK_METADATA_RELS = {"canonical"}
+SVG_ACTIVE_IRI_ELEMENTS = {
+ "use",
+ "script",
+ "mpath",
+ "textpath",
+ "tref",
+ "cursor",
+ "animate",
+ "animatemotion",
+ "animatetransform",
+ "set",
+}
+CSS_PRESENTATION_ATTRIBUTES = {
+ "clip-path",
+ "color-profile",
+ "cursor",
+ "fill",
+ "filter",
+ "marker",
+ "marker-start",
+ "marker-mid",
+ "marker-end",
+ "mask",
+ "stroke",
+}
+RUNTIME_ACTIVE_URL_ATTRIBUTES = {
+ "data-td-index-src",
+ "data-td-url",
+ "data-td-action-url",
+}
+RUNTIME_IMAGE_URL_ATTRIBUTES = {"data-td-image-zoom"}
def is_inert_oink_diagram_source(tag: str, values: dict[str, str]) -> bool:
@@ -151,17 +220,195 @@ def css_http_resources(value: str) -> list[str]:
def css_resource_urls(value: str) -> list[str]:
- """Extract CSS url() and quoted @import resources in source order."""
- pattern = re.compile(
- r"url\(\s*(?P<quote>['\"]?)(?P<url>[^'\"\s)]+)(?P=quote)\s*\)"
- r"|@import\s+(?P<import_quote>['\"])(?P<import_url>[^'\"]+)"
- r"(?P=import_quote)",
- re.IGNORECASE,
- )
- return [
- match.group("url") or match.group("import_url")
- for match in pattern.finditer(value)
- ]
+ """Extract browser request URLs from CSS without regex token ambiguity."""
+
+ def consume_escape(source: str, position: int) -> tuple[str, int]:
+ position += 1
+ if position >= len(source):
+ return "", position
+ if source[position] in "\r\n\f":
+ if source[position] == "\r" and position + 1 < len(source):
+ position += source[position + 1] == "\n"
+ return "", position + 1
+ end = position
+ while (
+ end < len(source)
+ and end - position < 6
+ and source[end] in "0123456789abcdefABCDEF"
+ ):
+ end += 1
+ if end > position:
+ codepoint = int(source[position:end], 16)
+ if end < len(source) and source[end].isspace():
+ end += 1
+ if codepoint == 0 or codepoint > 0x10FFFF or 0xD800 <= codepoint <= 0xDFFF:
+ return "\N{REPLACEMENT CHARACTER}", end
+ return chr(codepoint), end
+ return source[position], position + 1
+
+ def skip_space_and_comments(source: str, position: int) -> int:
+ while position < len(source):
+ if source[position].isspace():
+ position += 1
+ elif source.startswith("/*", position):
+ closing = source.find("*/", position + 2)
+ position = len(source) if closing < 0 else closing + 2
+ else:
+ break
+ return position
+
+ def consume_name(source: str, position: int) -> tuple[str, int]:
+ decoded: list[str] = []
+ while position < len(source):
+ char = source[position]
+ if char == "\\":
+ escaped, position = consume_escape(source, position)
+ decoded.append(escaped)
+ elif char.isalnum() or char in "_-" or ord(char) >= 0x80:
+ decoded.append(char)
+ position += 1
+ else:
+ break
+ return "".join(decoded), position
+
+ def consume_string(source: str, position: int) -> tuple[str, int]:
+ quote = source[position]
+ position += 1
+ decoded: list[str] = []
+ while position < len(source):
+ char = source[position]
+ if char == quote:
+ return "".join(decoded), position + 1
+ if char == "\\":
+ escaped, position = consume_escape(source, position)
+ decoded.append(escaped)
+ continue
+ decoded.append(char)
+ position += 1
+ return "".join(decoded), position
+
+ def matching_paren(source: str, position: int) -> int:
+ depth = 1
+ while position < len(source):
+ if source.startswith("/*", position):
+ position = skip_space_and_comments(source, position)
+ continue
+ char = source[position]
+ if char in "'\"":
+ _string, position = consume_string(source, position)
+ elif char == "\\":
+ _escaped, position = consume_escape(source, position)
+ else:
+ depth += char == "("
+ depth -= char == ")"
+ position += 1
+ if depth == 0:
+ return position - 1
+ return len(source)
+
+ def consume_url_function(source: str, position: int) -> tuple[str, int]:
+ position = skip_space_and_comments(source, position)
+ if position < len(source) and source[position] in "'\"":
+ resource, position = consume_string(source, position)
+ position = skip_space_and_comments(source, position)
+ closed = position < len(source) and source[position] == ")"
+ return resource, position + closed
+
+ decoded: list[str] = []
+ while position < len(source):
+ if source.startswith("/*", position):
+ position = skip_space_and_comments(source, position)
+ continue
+ char = source[position]
+ if char == ")":
+ return "".join(decoded).strip(), position + 1
+ if char.isspace():
+ position = skip_space_and_comments(source, position)
+ while position < len(source) and source[position] != ")":
+ position += 1
+ return "".join(decoded), position + (position < len(source))
+ if char == "\\":
+ escaped, position = consume_escape(source, position)
+ decoded.append(escaped)
+ continue
+ decoded.append(char)
+ position += 1
+ return "".join(decoded).strip(), position
+
+ def scan(source: str) -> list[str]:
+ resources: list[str] = []
+ position = 0
+ while position < len(source):
+ if source.startswith("/*", position) or source[position].isspace():
+ position = skip_space_and_comments(source, position)
+ continue
+ if source[position] in "'\"":
+ _string, position = consume_string(source, position)
+ continue
+ if source[position] == "@":
+ name, after_name = consume_name(source, position + 1)
+ if name.lower() == "import":
+ candidate = skip_space_and_comments(source, after_name)
+ if candidate < len(source) and source[candidate] in "'\"":
+ resource, position = consume_string(source, candidate)
+ resources.append(resource)
+ continue
+ position = max(after_name, position + 1)
+ continue
+ if (
+ source[position].isalnum()
+ or source[position] in "_-\\"
+ or ord(source[position]) >= 0x80
+ ):
+ name, after_name = consume_name(source, position)
+ opening = skip_space_and_comments(source, after_name)
+ if opening >= len(source) or source[opening] != "(":
+ position = max(after_name, position + 1)
+ continue
+ lowered = name.lower()
+ if lowered == "url":
+ resource, position = consume_url_function(source, opening + 1)
+ if resource:
+ resources.append(resource)
+ continue
+ if lowered in {"image-set", "-webkit-image-set"}:
+ closing = matching_paren(source, opening + 1)
+ body = source[opening + 1 : closing]
+ candidate_start = 0
+ depth = 0
+ cursor = 0
+ while cursor <= len(body):
+ at_end = cursor == len(body)
+ if not at_end and body.startswith("/*", cursor):
+ cursor = skip_space_and_comments(body, cursor)
+ continue
+ if not at_end and body[cursor] in "'\"":
+ _string, cursor = consume_string(body, cursor)
+ continue
+ if not at_end and body[cursor] == "\\":
+ _escaped, cursor = consume_escape(body, cursor)
+ continue
+ if not at_end:
+ depth += body[cursor] == "("
+ depth -= body[cursor] == ")"
+ if at_end or (body[cursor] == "," and depth == 0):
+ candidate = body[candidate_start:cursor]
+ first = skip_space_and_comments(candidate, 0)
+ if first < len(candidate) and candidate[first] in "'\"":
+ resource, _end = consume_string(candidate, first)
+ resources.append(resource)
+ else:
+ resources.extend(scan(candidate))
+ candidate_start = cursor + 1
+ cursor += 1
+ position = closing + (closing < len(source))
+ continue
+ position = opening + 1
+ continue
+ position += 1
+ return resources
+
+ return scan(value)
def css_external_resources(
@@ -199,10 +446,59 @@ def image_url_allowed_by_asf_csp(
return hostname in ASF_CSP_IMAGE_HOSTS or hostname.endswith(ASF_CSP_IMAGE_SUFFIXES)
-def error_document_seo_errors(parser: DocumentParser, page_name: str) -> list[str]:
+def error_document_paths(root: pathlib.Path | None = None) -> set[str]:
+ """Return root and version-scoped error documents from the active manifest."""
+
+ manifest_paths = []
+ if root is not None:
+ manifest_paths.append(root / "build-metadata/versions.json")
+ manifest_paths.append(pathlib.Path(__file__).resolve().parents[1] / "versions.json")
+ manifest_path = next((path for path in manifest_paths if path.is_file()), None)
+ if manifest_path is None:
+ return set(ERROR_DOCUMENT_PATHS)
+
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
+ raise ValueError(f"cannot parse version manifest {manifest_path}: {exc}") from exc
+ versions = manifest.get("versions")
+ if not isinstance(versions, list):
+ raise ValueError(f"version manifest {manifest_path} has no versions list")
+
+ paths = set(ERROR_DOCUMENT_PATHS)
+ for entry in versions:
+ publish_path = entry.get("publishPath") if isinstance(entry, dict) else None
+ if not isinstance(publish_path, str):
+ raise ValueError(
+ f"version manifest {manifest_path} has invalid publishPath"
+ )
+ if not publish_path:
+ continue
+ pure_path = pathlib.PurePosixPath(publish_path)
+ if (
+ pure_path.is_absolute()
+ or ".." in pure_path.parts
+ or pure_path.as_posix() != publish_path
+ ):
+ raise ValueError(
+ f"version manifest {manifest_path} has unsafe publishPath "
+ f"{publish_path!r}"
+ )
+ paths.add(f"{publish_path}/404.html")
+ paths.add(f"{publish_path}/cn/404.html")
+ return paths
+
+
+def error_document_seo_errors(
+ parser: DocumentParser,
+ page_name: str,
+ error_paths: set[str] | None = None,
+) -> list[str]:
"""Require error documents to stay out of indexes and canonical clusters."""
- if page_name not in ERROR_DOCUMENT_PATHS:
+ if page_name not in (
+ error_paths if error_paths is not None else error_document_paths()
+ ):
return []
errors: list[str] = []
@@ -304,6 +600,10 @@ def document_security_errors(
f"{page_name}: unsafe content markup: {violation}"
for violation in parser.authored_violations
]
+ if parser._content_markers[0] != parser._content_markers[1]:
+ errors.append(
+ f"{page_name}: unsafe content markup: unbalanced authored-content markers"
+ )
errors.extend(
f"{page_name}: mixed-content CSS resource: {resource}"
for resource in parser.inline_css_http_resources
@@ -323,7 +623,14 @@ def document_security_errors(
f"{page_name}: external active resource is forbidden <{tag}> "
f"{attribute}: {resource}"
for tag, attribute, resource in parser.resources
- if (tag, attribute) in EXTERNAL_ACTIVE_RESOURCE_ATTRIBUTES
+ if (
+ (tag, attribute) in EXTERNAL_ACTIVE_RESOURCE_ATTRIBUTES
+ or attribute in RUNTIME_ACTIVE_URL_ATTRIBUTES
+ or (
+ attribute in {"href", "xlink:href"}
+ and tag not in {"a", "image", "feimage"}
+ )
+ )
and urllib.parse.urlsplit(resource.strip()).netloc
and urllib.parse.urlsplit(resource.strip()).netloc != base_parts.netloc
and urllib.parse.urlsplit(resource.strip()).scheme.lower() != "http"
@@ -346,6 +653,7 @@ class DocumentParser(html.parser.HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.urls: list[tuple[str, str]] = []
+ self.navigation_urls: list[tuple[str, str, str]] = []
self.canonical: list[str] = []
self.hreflang: list[tuple[str, str]] = []
self.meta: list[dict[str, str]] = []
@@ -358,11 +666,49 @@ class DocumentParser(html.parser.HTMLParser):
self.action_manifest = ""
self._in_action_manifest = False
self._content_depth = 0
+ self._content_markers = [0, 0]
+ self._in_content_marker = False
self._in_style = False
+ self._svg_depth = 0
+ self._media_elements: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
+ if tag in {"audio", "video"}:
+ self._media_elements.append(tag)
+ in_svg = bool(self._svg_depth) or tag == "svg"
+ if tag == "svg":
+ self._svg_depth += 1
+ seen_attributes: set[str] = set()
+ duplicate_attributes: set[str] = set()
+ for key, _value in attrs:
+ attribute = key.lower()
+ if attribute in seen_attributes and attribute not in duplicate_attributes:
+ self.authored_violations.append(
+ f"duplicate {attribute} attribute on <{tag}>"
+ )
+ duplicate_attributes.add(attribute)
+ seen_attributes.add(attribute)
values = {key.lower(): value or "" for key, value in attrs}
+ content_marker = (
+ values.get("data-hg-authored-content")
+ if tag == "template"
+ else None
+ )
+ if content_marker == "start":
+ self._content_markers[0] += 1
+ if self._in_content_marker:
+ self.authored_violations.append(
+ "nested authored-content start marker"
+ )
+ self._in_content_marker = True
+ elif content_marker == "end":
+ self._content_markers[1] += 1
+ if not self._in_content_marker:
+ self.authored_violations.append(
+ "unexpected authored-content end marker"
+ )
+ self._in_content_marker = False
if tag == "nav" and "TableOfContents" in [
value or "" for key, value in attrs if key.lower() == "id"
]:
@@ -371,7 +717,7 @@ class DocumentParser(html.parser.HTMLParser):
)
if tag in {"main", "article"}:
self._content_depth += 1
- if self._content_depth:
+ if self._content_depth or self._in_content_marker:
if tag in UNSAFE_AUTHORED_ELEMENTS and not is_inert_oink_diagram_source(
tag, values
):
@@ -382,7 +728,10 @@ class DocumentParser(html.parser.HTMLParser):
f"authored {attribute} event attribute on <{tag}>"
)
- if tag in {"a", "link"} and values.get("href"):
+ if tag in {"a", "area"} and values.get("href"):
+ self.urls.append(("href", values["href"]))
+ self.navigation_urls.append((tag, "href", values["href"]))
+ if tag == "link" and values.get("href"):
self.urls.append(("href", values["href"]))
if tag in {"img", "script", "source"} and values.get("src"):
self.urls.append(("src", values["src"]))
@@ -394,32 +743,100 @@ class DocumentParser(html.parser.HTMLParser):
resource_attributes.append(("poster", values["poster"]))
if tag == "object" and values.get("data"):
resource_attributes.append(("data", values["data"]))
+ self.urls.append(("data", values["data"]))
+ if tag == "form" and values.get("action"):
+ resource_attributes.append(("action", values["action"]))
+ self.urls.append(("action", values["action"]))
+ if tag in {"button", "input"} and values.get("formaction"):
+ resource_attributes.append(("formaction", values["formaction"]))
+ self.urls.append(("formaction", values["formaction"]))
+ if tag in SVG_ACTIVE_IRI_ELEMENTS | {"image", "feimage"}:
+ for attribute in ("href", "xlink:href"):
+ if not values.get(attribute):
+ continue
+ if (attribute, values[attribute]) not in resource_attributes:
+ resource_attributes.append((attribute, values[attribute]))
+ if (attribute, values[attribute]) not in self.urls:
+ self.urls.append((attribute, values[attribute]))
+ if (
+ in_svg
+ and tag not in {"a", "image", "feimage", "link"}
+ and values.get("href")
+ ):
+ if ("href", values["href"]) not in resource_attributes:
+ resource_attributes.append(("href", values["href"]))
+ if ("href", values["href"]) not in self.urls:
+ self.urls.append(("href", values["href"]))
+ if tag == "a" and values.get("xlink:href"):
+ self.urls.append(("xlink:href", values["xlink:href"]))
+ self.navigation_urls.append((tag, "xlink:href", values["xlink:href"]))
+ elif (
+ values.get("xlink:href")
+ and tag not in SVG_ACTIVE_IRI_ELEMENTS | {"image", "feimage"}
+ ):
+ resource_attributes.append(("xlink:href", values["xlink:href"]))
+ self.urls.append(("xlink:href", values["xlink:href"]))
if tag == "iframe" and values.get("src"):
resource_attributes.append(("src", values["src"]))
+ if tag == "frame" and values.get("src"):
+ resource_attributes.append(("src", values["src"]))
+ self.urls.append(("src", values["src"]))
if (
tag == "input"
and values.get("type", "").lower() == "image"
and values.get("src")
):
resource_attributes.append(("src", values["src"]))
- if tag == "image" and values.get("href"):
- resource_attributes.append(("href", values["href"]))
if tag == "link" and values.get("href"):
rel = set(values.get("rel", "").lower().split())
- if rel & {
- "stylesheet",
- "preload",
- "modulepreload",
- "icon",
- "apple-touch-icon",
- "manifest",
- }:
+ is_metadata = rel == LINK_METADATA_RELS or (
+ rel == {"alternate"} and bool(values.get("hreflang"))
+ )
+ if rel & LINK_RESOURCE_RELS or not is_metadata:
resource_attributes.append(("href", values["href"]))
+ else:
+ self.navigation_urls.append((tag, "href", values["href"]))
+ if tag == "link" and values.get("imagesrcset"):
+ urls = srcset_urls(values["imagesrcset"])
+ resource_attributes.extend(
+ ("imagesrcset", url) for url in urls
+ )
+ self.urls.extend(("imagesrcset", url) for url in urls)
+ self.image_urls.extend(
+ (tag, "imagesrcset", url) for url in urls
+ )
+ if tag in {"a", "area"} and "ping" in values:
+ self.authored_violations.append(f"forbidden ping attribute on <{tag}>")
+ if tag == "base" and "href" in values:
+ self.authored_violations.append("forbidden base[href]")
+ if tag == "iframe" and "srcdoc" in values:
+ self.authored_violations.append("forbidden iframe[srcdoc]")
+ if "attributionsrc" in values:
+ self.authored_violations.append(
+ f"forbidden attributionsrc attribute on <{tag}>"
+ )
+ if tag in {"body", "table", "td", "th"} and values.get("background"):
+ resource_attributes.append(("background", values["background"]))
+ self.urls.append(("background", values["background"]))
+ self.image_urls.append((tag, "background", values["background"]))
+ for attribute in RUNTIME_ACTIVE_URL_ATTRIBUTES | RUNTIME_IMAGE_URL_ATTRIBUTES:
+ if not values.get(attribute):
+ continue
+ resource_attributes.append((attribute, values[attribute]))
+ self.urls.append((attribute, values[attribute]))
+ if attribute in RUNTIME_IMAGE_URL_ATTRIBUTES:
+ self.image_urls.append((tag, attribute, values[attribute]))
+ resource_tag = (
+ "media-source"
+ if tag == "source" and self._media_elements
+ else tag
+ )
self.resources.extend(
- (tag, attribute, url) for attribute, url in resource_attributes
+ (resource_tag, attribute, url)
+ for attribute, url in resource_attributes
)
- if tag in {"img", "source"}:
+ if tag == "img" or (tag == "source" and not self._media_elements):
for attribute in ("src", "srcset"):
if not values.get(attribute):
continue
@@ -430,7 +847,16 @@ class DocumentParser(html.parser.HTMLParser):
)
self.image_urls.extend((tag, attribute, url) for url in urls)
if attribute == "srcset":
- self.resources.extend((tag, attribute, url) for url in urls)
+ self.resources.extend(
+ (resource_tag, attribute, url) for url in urls
+ )
+ self.urls.extend((attribute, url) for url in urls)
+ elif tag == "source" and values.get("srcset"):
+ urls = srcset_urls(values["srcset"])
+ self.resources.extend(
+ (resource_tag, "srcset", url) for url in urls
+ )
+ self.urls.extend(("srcset", url) for url in urls)
if tag == "video" and values.get("poster"):
self.image_urls.append((tag, "poster", values["poster"]))
if (
@@ -439,12 +865,20 @@ class DocumentParser(html.parser.HTMLParser):
and values.get("src")
):
self.image_urls.append((tag, "src", values["src"]))
- if tag == "image" and values.get("href"):
- self.image_urls.append((tag, "href", values["href"]))
+ if tag in {"image", "feimage"}:
+ for attribute in ("href", "xlink:href"):
+ if values.get(attribute):
+ self.image_urls.append((tag, attribute, values[attribute]))
if values.get("style"):
self.inline_css_sources.append(values["style"])
self.inline_css_http_resources.extend(css_http_resources(values["style"]))
+ for attribute in CSS_PRESENTATION_ATTRIBUTES:
+ if values.get(attribute):
+ self.inline_css_sources.append(values[attribute])
+ self.inline_css_http_resources.extend(
+ css_http_resources(values[attribute])
+ )
if tag == "link" and values.get("rel", "").lower() == "canonical":
self.canonical.append(values.get("href", ""))
if (
@@ -468,10 +902,24 @@ class DocumentParser(html.parser.HTMLParser):
self.inline_css_http_resources.extend(css_http_resources(data))
def handle_endtag(self, tag: str) -> None:
+ tag = tag.lower()
if tag == "script" and self._in_action_manifest:
self._in_action_manifest = False
if tag == "style":
self._in_style = False
+ if tag == "svg" and self._svg_depth:
+ self._svg_depth -= 1
+ if tag in {"audio", "video"}:
+ if self._media_elements and self._media_elements[-1] == tag:
+ self._media_elements.pop()
+ elif self._media_elements:
+ self.authored_violations.append(
+ f"mismatched </{tag}> inside <{self._media_elements[-1]}>"
+ )
+ else:
+ self.authored_violations.append(
+ f"unmatched </{tag}>"
+ )
if tag in {"main", "article"} and self._content_depth:
self._content_depth -= 1
@@ -514,6 +962,44 @@ def internal_output_target(
return output_path(root, parts.path or "/")
+def css_internal_output_target(
+ root: pathlib.Path,
+ stylesheet: pathlib.Path,
+ base_parts: urllib.parse.SplitResult,
+ url: str,
+) -> pathlib.Path | None:
+ """Resolve one same-origin CSS request against its emitted stylesheet."""
+
+ parts = urllib.parse.urlsplit(url)
+ if parts.netloc and parts.netloc != base_parts.netloc:
+ return None
+ decoded = urllib.parse.unquote(parts.path)
+ if not decoded:
+ return None if parts.fragment else stylesheet
+ if "\x00" in decoded or "\\" in decoded:
+ raise ValueError("contains a NUL or backslash")
+
+ if decoded.startswith("/"):
+ artifact_base = urllib.parse.unquote(base_parts.path).rstrip("/")
+ if artifact_base and (
+ decoded == artifact_base or decoded.startswith(artifact_base + "/")
+ ):
+ decoded = decoded[len(artifact_base) :] or "/"
+ candidate = root / decoded.lstrip("/")
+ else:
+ candidate = stylesheet.parent / decoded
+ candidate = candidate.resolve()
+ try:
+ candidate.relative_to(root)
+ except ValueError as exc:
+ raise ValueError("escapes output directory") from exc
+ if decoded.endswith("/") or (
+ not candidate.is_file() and not pathlib.PurePosixPath(decoded).suffix
+ ):
+ candidate /= "index.html"
+ return candidate
+
+
def refresh_target(parser: DocumentParser) -> str | None:
refresh = [
item.get("content", "")
@@ -530,6 +1016,81 @@ def refresh_target(parser: DocumentParser) -> str | None:
return match.group(1).strip(" \"'")
+def rendered_url_shape_error(
+ value: str,
+ page_name: str,
+ attribute: str,
+ *,
+ allow_contact: bool = False,
+) -> str | None:
+ """Reject URL spellings that browsers and RFC parsers interpret differently."""
+ if any(
+ char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value
+ ):
+ return (
+ f"{page_name}: unsafe whitespace/control URL in {attribute}: {value}"
+ )
+ if "\\" in value:
+ return f"{page_name}: unsafe backslash URL in {attribute}: {value}"
+ if value.startswith("//"):
+ return f"{page_name}: protocol-relative {attribute} is forbidden: {value}"
+ try:
+ parts = urllib.parse.urlsplit(value)
+ except ValueError as exc:
+ return f"{page_name}: malformed URL in {attribute}: {value}: {exc}"
+ if parts.scheme.lower() in {"http", "https"} and not parts.netloc:
+ return f"{page_name}: HTTP(S) URL has no authority in {attribute}: {value}"
+ allowed_schemes = {"", "http", "https"}
+ if allow_contact:
+ allowed_schemes.update({"mailto", "tel"})
+ if parts.scheme.lower() not in allowed_schemes:
+ return f"{page_name}: forbidden URL scheme in {attribute}: {value}"
+ return None
+
+
+def document_url_shape_errors(
+ parser: DocumentParser,
+ page_name: str,
+) -> list[str]:
+ """Apply the browser-safe URL shape contract to every rendered URL token."""
+ tokens: list[tuple[str, str, bool]] = [
+ (f"{tag}[{attribute}]", value, tag in {"a", "area"})
+ for tag, attribute, value in parser.navigation_urls
+ ]
+ tokens.extend(
+ (f"{tag}[{attribute}]", value, False)
+ for tag, attribute, value in parser.resources
+ )
+ tokens.extend(
+ ("inline CSS", value, False)
+ for source in parser.inline_css_sources
+ for value in css_resource_urls(source)
+ )
+ try:
+ alias_target = refresh_target(parser)
+ except ValueError as exc:
+ return [f"{page_name}: {exc}"]
+ if alias_target:
+ tokens.append(("meta refresh", alias_target, False))
+
+ errors = []
+ seen: set[tuple[str, str, bool]] = set()
+ for attribute, value, allow_contact in tokens:
+ key = (attribute, value, allow_contact)
+ if key in seen:
+ continue
+ seen.add(key)
+ error = rendered_url_shape_error(
+ value,
+ page_name,
+ attribute,
+ allow_contact=allow_contact,
+ )
+ if error:
+ errors.append(error)
+ return errors
+
+
def main() -> int:
argument_parser = argparse.ArgumentParser(
description="Validate a generated HugeGraph documentation artifact."
@@ -550,6 +1111,11 @@ def main() -> int:
base = args.expected_base_url.rstrip("/") + "/"
base_parts = urllib.parse.urlsplit(base)
errors: list[str] = []
+ try:
+ error_paths = error_document_paths(root)
+ except ValueError as exc:
+ errors.append(str(exc))
+ error_paths = set(ERROR_DOCUMENT_PATHS)
if not root.is_dir():
errors.append(f"missing output directory: {root}")
@@ -591,8 +1157,12 @@ def main() -> int:
continue
page_name = page.relative_to(root).as_posix()
+ shape_errors = document_url_shape_errors(parser, page_name)
+ errors.extend(shape_errors)
+ if shape_errors:
+ continue
errors.extend(document_security_errors(parser, page_name, base_parts))
- errors.extend(error_document_seo_errors(parser, page_name))
+ errors.extend(error_document_seo_errors(parser, page_name, error_paths))
if args.security_only:
continue
errors.extend(toc_accessibility_errors(parser, page_name))
@@ -601,7 +1171,7 @@ def main() -> int:
except ValueError as exc:
errors.append(f"{page_name}: {exc}")
alias_target = None
- is_error_document = page_name in ERROR_DOCUMENT_PATHS
+ is_error_document = page_name in error_paths
if not is_error_document and page_name != "client-go/index.html":
if len(parser.canonical) != 1:
errors.append(
@@ -708,7 +1278,14 @@ def main() -> int:
)
for attribute, raw_url in parser.urls:
- url = raw_url.strip()
+ if rendered_url_shape_error(
+ raw_url,
+ page_name,
+ attribute,
+ allow_contact=True,
+ ):
+ continue
+ url = raw_url
lower_url = url.lower()
if "/_nav/" in lower_url:
errors.append(
@@ -721,12 +1298,6 @@ def main() -> int:
or lower_url.startswith(("mailto:", "tel:"))
):
continue
- if url.startswith("//"):
- errors.append(
- f"{page_name}: protocol-relative {attribute} is forbidden: {url}"
- )
- continue
-
parts = urllib.parse.urlsplit(url)
if parts.scheme and parts.scheme.lower() not in {"http", "https"}:
errors.append(
@@ -761,6 +1332,21 @@ def main() -> int:
except (OSError, UnicodeError) as exc:
errors.append(f"cannot parse {stylesheet.relative_to(root)}: {exc}")
continue
+ stylesheet_name = stylesheet.relative_to(root).as_posix()
+ shape_errors = [
+ error
+ for resource in css_resource_urls(stylesheet_text)
+ if (
+ error := rendered_url_shape_error(
+ resource,
+ stylesheet_name,
+ "CSS resource",
+ )
+ )
+ ]
+ errors.extend(shape_errors)
+ if shape_errors:
+ continue
for resource in css_http_resources(stylesheet_text):
errors.append(
f"{stylesheet.relative_to(root)}: mixed-content CSS resource: {resource}"
@@ -770,6 +1356,22 @@ def main() -> int:
f"{stylesheet.relative_to(root)}: external CSS resource is forbidden: "
f"{resource}"
)
+ for resource in css_resource_urls(stylesheet_text):
+ try:
+ target = css_internal_output_target(
+ root, stylesheet, base_parts, resource
+ )
+ except ValueError as exc:
+ errors.append(
+ f"{stylesheet_name}: unsafe internal CSS resource "
+ f"{resource}: {exc}"
+ )
+ continue
+ if target is not None and not target.is_file():
+ errors.append(
+ f"{stylesheet_name}: broken internal CSS resource {resource} -> "
+ f"{target.relative_to(root)}"
+ )
if args.security_only:
if errors:
diff --git a/hugo.yaml b/hugo.yaml
index 8b0afe361..d276b93c5 100644
--- a/hugo.yaml
+++ b/hugo.yaml
@@ -17,13 +17,14 @@ languages:
params:
description: Apache HugeGraph documentation and project updates.
version_menu: Releases
- versions:
- - { version: latest, name: latest, url: 'https://hugegraph.apache.org/docs/', pagelinks: false }
- - { version: '1.7', name: '1.7', url: 'https://hugegraph.apache.org/versions/1.7/docs/', pagelinks: false }
- - { version: '1.5', name: '1.5', url: 'https://hugegraph.apache.org/versions/1.5/docs/', pagelinks: false }
menus:
main:
- { identifier: docs, name: Documentation, pageRef: /docs, weight: 10 }
+ - { identifier: docs-start, parent: docs, name: Get Started, pageRef: /docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } }
+ - { identifier: docs-components, parent: docs, name: Components, pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } }
+ - { identifier: docs-develop, parent: docs, name: Develop, pageRef: /docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } }
+ - { identifier: docs-operate, parent: docs, name: Operate, pageRef: /docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } }
+ - { identifier: docs-reference, parent: docs, name: Reference, pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } }
- { identifier: download, name: Download, pageRef: /docs/download/download, weight: 20 }
- { identifier: blog, name: Blog, pageRef: /blog, weight: 30 }
- { identifier: community, name: Community, pageRef: /community, weight: 40 }
@@ -38,13 +39,14 @@ languages:
params:
description: Apache HugeGraph 中文文档与项目动态。
version_menu: 版本
- versions:
- - { version: latest, name: latest, url: 'https://hugegraph.apache.org/cn/docs/', pagelinks: false }
- - { version: '1.7', name: '1.7', url: 'https://hugegraph.apache.org/versions/1.7/cn/docs/', pagelinks: false }
- - { version: '1.5', name: '1.5', url: 'https://hugegraph.apache.org/versions/1.5/cn/docs/', pagelinks: false }
menus:
main:
- { identifier: docs, name: 文档, pageRef: /docs, weight: 10 }
+ - { identifier: docs-start, parent: docs, name: 开始, pageRef: /docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } }
+ - { identifier: docs-components, parent: docs, name: 组件, pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } }
+ - { identifier: docs-develop, parent: docs, name: 开发, pageRef: /docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } }
+ - { identifier: docs-operate, parent: docs, name: 运维, pageRef: /docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } }
+ - { identifier: docs-reference, parent: docs, name: 参考, pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } }
- { identifier: download, name: 下载, pageRef: /docs/download/download, weight: 20 }
- { identifier: blog, name: 博客, pageRef: /blog, weight: 30 }
- { identifier: community, name: 社区, pageRef: /community, weight: 40 }
@@ -86,6 +88,7 @@ outputs:
params:
description: Apache HugeGraph is a full-stack graph database ecosystem for OLTP, OLAP, and graph AI.
+ images: [/img/social/hugegraph-default.png]
github_repo: https://github.com/apache/hugegraph-doc
github_project_repo: https://github.com/apache/hugegraph
github_branch: master
@@ -102,12 +105,22 @@ params:
offline_search_index: summary
offline_search_summary_length: 70
offline_search_max_results: 10
+ # Optional enhancement: keep disabled until both reviewed latest-only Kapa
+ # source groups and the staging CSP/corpus acceptance gates pass.
+ ai_search:
+ enabled: false
+ provider: kapa
+ website_id: 0b277570-4740-451e-96fa-1e4ac1ac5e88
+ source_groups:
+ en: ''
+ cn: ''
footer_center_info: ''
# The compact ASF footer owns the legal line; global controls stay in header.
copyright: false
print:
toc: true
ui:
+ theme_color: '#532fc9'
dark_mode:
enable: true
show_menu: true
@@ -120,6 +133,8 @@ params:
wide_nav_sections: [community]
sidebar_icon_policy: groups
sidebar_item_overflow: wrap
+ backlinks: true
+ image_zoom: true
page_context_menu:
enable: true
assistant_links: false
diff --git a/i18n/en.yaml b/i18n/en.yaml
new file mode 100644
index 000000000..a7cea091c
--- /dev/null
+++ b/i18n/en.yaml
@@ -0,0 +1,27 @@
+ui_version_fallback: This page is not available in the selected version. You have been redirected to that version's documentation home.
+ui_ask_ai: Ask AI
+ui_ask_ai_description: Powered by Kapa; only your question is sent.
+ui_ask_ai_latest: Answers use the latest documentation
+ui_retry: Retry
+ui_ai_error: AI is temporarily unavailable. Local search is unaffected.
+download_release_version: Version
+download_release_date: Release Date
+download_release_notes: Release Notes
+download_table_component: Component
+download_table_type: Type
+download_table_mirror: Download (ASF mirror)
+download_type_binary: Binary
+download_type_source: Source
+download_component_server: Server
+download_component_toolchain: Toolchain
+download_component_ai: AI
+download_component_computer: Computer
+download_component_commons: Common
+download_asf_note: All packages on this page are official Apache Software Foundation releases served from ASF mirrors, with signatures and checksums hosted on downloads.apache.org. Source archives generated automatically by GitHub are not ASF releases.
+ui_assets_download: Download asset
+
+ui_more_versions: More versions
+ui_ai_consent_description: "Continuing loads Kapa, a third-party service. Your question will be sent to Kapa to generate an answer."
+ui_ai_consent_continue: "Continue to Ask AI"
+ui_ai_consent_cancel: "Cancel"
+ui_ai_privacy: "Privacy policy"
diff --git a/i18n/cn.yaml b/i18n/zh-CN.yaml
similarity index 86%
rename from i18n/cn.yaml
rename to i18n/zh-CN.yaml
index d1c584ea5..1c9f814e2 100644
--- a/i18n/cn.yaml
+++ b/i18n/zh-CN.yaml
@@ -234,3 +234,29 @@ ui_diagram_zoom_in: 放大
ui_diagram_zoom_out: 缩小
ui_diagram_zoom_reset: 复位视图
ui_diagram_error: 图表无法渲染
+ui_version_fallback: 目标版本没有此页面,已转到该版本的文档首页。
+ui_ask_ai: 询问 AI
+ui_ask_ai_description: 由 Kapa 提供;仅发送你的问题。
+ui_ask_ai_latest: 回答基于 latest 文档
+ui_retry: 重试
+ui_ai_error: AI 暂时不可用,本地搜索不受影响。
+download_release_version: 版本
+download_release_date: 发布日期
+download_release_notes: 发布说明
+download_table_component: 组件
+download_table_type: 类型
+download_table_mirror: 下载 (ASF 镜像)
+download_type_binary: 二进制
+download_type_source: 源码
+download_component_server: Server
+download_component_toolchain: Toolchain
+download_component_ai: AI
+download_component_computer: Computer
+download_component_commons: Common
+download_asf_note: 本页所列软件包均为 Apache 软件基金会正式发布版本,通过 ASF 镜像分发,签名与校验和托管在 downloads.apache.org。GitHub 自动生成的源码归档不是 ASF 正式发布版本。
+
+ui_more_versions: 更多版本
+ui_ai_consent_description: "继续后将加载第三方服务 Kapa,并将你的问题发送给 Kapa 生成回答。"
+ui_ai_consent_continue: "继续使用 Ask AI"
+ui_ai_consent_cancel: "取消"
+ui_ai_privacy: "隐私政策"
diff --git a/layouts/_partials/actions/manifest.html b/layouts/_partials/actions/manifest.html
new file mode 100644
index 000000000..3029720f3
--- /dev/null
+++ b/layouts/_partials/actions/manifest.html
@@ -0,0 +1,23 @@
+{{- $outputFormat := lower (.Store.Get "tdOutputFormat" | default "html") -}}
+{{- $context := partialCached "actions/context.html" . .Site.BaseURL .Site.Language.Lang .RelPermalink $outputFormat -}}
+{{- $versionOptions := partial "version-options.html" . -}}
+{{- $actions := slice -}}
+{{- range $context.actions -}}
+ {{- $action := . -}}
+ {{- if eq .id "switch_version" -}}
+ {{- $action = merge . (dict
+ "options" $versionOptions
+ "available" (gt (len $versionOptions) 0)
+ "disabledReason" (cond (gt (len $versionOptions) 0) "" (printf "%s: %s" .title (T "ui_action_unavailable")))
+ ) -}}
+ {{- end -}}
+ {{- $actions = $actions | append $action -}}
+{{- end -}}
+{{- return (dict
+ "version" 1
+ "language" .Site.Language.Lang
+ "actions" $actions
+ "commands" (partialCached "actions/site-commands.html" . .Site.Language.Lang .Site.BaseURL)
+ "quickLinks" (partialCached "actions/quick-links.html" . .Site.Language.Lang .Site.BaseURL)
+ "rootOrder" (partialCached "actions/root-order.html" . .Site.Language.Lang .Site.BaseURL)
+) -}}
diff --git a/layouts/_partials/ai/config.html b/layouts/_partials/ai/config.html
new file mode 100644
index 000000000..4a0217381
--- /dev/null
+++ b/layouts/_partials/ai/config.html
@@ -0,0 +1,33 @@
+{{- $raw := .Site.Params.ai_search | default dict -}}
+{{- $enabled := false -}}
+{{- $provider := "" -}}
+{{- $websiteID := "" -}}
+{{- $sourceGroups := dict -}}
+{{- if reflect.IsMap $raw -}}
+ {{- $enabled = index $raw "enabled" | default false -}}
+ {{- if ne (printf "%T" $enabled) "bool" -}}
+ {{- errorf "params.ai_search.enabled must be a boolean" -}}
+ {{- end -}}
+ {{- $provider = index $raw "provider" | default "" -}}
+ {{- $websiteID = index $raw "website_id" | default "" -}}
+ {{- $sourceGroups = index $raw "source_groups" | default dict -}}
+{{- else -}}
+ {{- errorf "params.ai_search must be a map" -}}
+{{- end -}}
+{{- $enGroup := "" -}}
+{{- $cnGroup := "" -}}
+{{- if reflect.IsMap $sourceGroups -}}
+ {{- $enGroup = index $sourceGroups "en" | default "" -}}
+ {{- $cnGroup = index $sourceGroups "cn" | default "" -}}
+{{- end -}}
+{{- if $enabled -}}
+ {{- if ne $provider "kapa" }}{{ errorf "params.ai_search.provider must be kapa when AI search is enabled" }}{{ end -}}
+ {{- if not $websiteID }}{{ errorf "params.ai_search.website_id is required when AI search is enabled" }}{{ end -}}
+ {{- if or (not $enGroup) (not $cnGroup) }}{{ errorf "params.ai_search.source_groups.en and .cn are required when AI search is enabled" }}{{ end -}}
+{{- end -}}
+{{- return (dict
+ "enabled" $enabled
+ "provider" $provider
+ "websiteID" $websiteID
+ "sourceGroups" (dict "en" $enGroup "cn" $cnGroup)
+) -}}
diff --git a/layouts/_partials/asf-downloads.html b/layouts/_partials/asf-downloads.html
new file mode 100644
index 000000000..05cda83e5
--- /dev/null
+++ b/layouts/_partials/asf-downloads.html
@@ -0,0 +1,101 @@
+{{- /* Render ASF release download tables from data/downloads/asf.json.
+
+ The data file is the single source of truth for both languages; this
+ partial only derives presentation. Malformed site-owned data fails the
+ build via errorf so a broken table can never publish silently.
+
+ mode: "latest" renders the one latest release, "archived" renders the
+ rest. The mode is required so a page cannot render duplicate sections
+ (and duplicate element ids) by accident. */ -}}
+{{- $page := .page -}}
+{{- $mode := .mode -}}
+{{- $position := .position | default $page.Path -}}
+{{- if not (in (slice "latest" "archived") $mode) -}}
+ {{- errorf "%s: asf-downloads mode must be latest or archived; got %q" $position $mode -}}
+{{- end -}}
+{{- $data := index (index hugo.Data "downloads" | default dict) "asf" | default dict -}}
+{{- $dist := $data.dist_path | default "" -}}
+{{- $components := $data.components | default dict -}}
+{{- $releases := $data.releases | default slice -}}
+{{- if not (and $dist $components $releases) -}}
+ {{- errorf "%s: data/downloads/asf.json must define dist_path, components, and releases" $position -}}
+{{- end -}}
+{{- $selected := slice -}}
+{{- range $releases -}}
+ {{- $isLatest := .latest | default false -}}
+ {{- if or (and (eq $mode "latest") $isLatest) (and (eq $mode "archived") (not $isLatest)) -}}
+ {{- $selected = $selected | append . -}}
+ {{- end -}}
+{{- end -}}
+{{- range $release := $selected -}}
+{{- $version := $release.version -}}
+{{- if not $version -}}{{- errorf "%s: asf-downloads release without a version" $position -}}{{- end -}}
+{{- $infix := cond ($release.incubating | default false) "-incubating" "" -}}
+{{- $rows := slice -}}
+{{- range $kind := slice "binary" "source" -}}
+ {{- range $componentID := index $release $kind | default slice -}}
+ {{- $component := index $components $componentID -}}
+ {{- if not $component -}}
+ {{- errorf "%s: asf-downloads release %s references unknown component %q" $position $version $componentID -}}
+ {{- end -}}
+ {{- if not (and $component.prefix $component.label_key) -}}
+ {{- errorf "%s: asf-downloads component %q must define prefix and label_key" $position $componentID -}}
+ {{- end -}}
+ {{- $suffix := cond (eq $kind "source") "-src" "" -}}
+ {{- $file := printf "%s%s-%s%s.tar.gz" $component.prefix $infix $version $suffix -}}
+ {{- $rows = $rows | append (dict
+ "label" (T $component.label_key)
+ "kind" $kind
+ "file" $file
+ "mirror" (printf "https://www.apache.org/dyn/closer.lua/%s/%s/%s?action=download" $dist $version $file)
+ "asc" (printf "https://downloads.apache.org/%s/%s/%s.asc" $dist $version $file)
+ "sha512" (printf "https://downloads.apache.org/%s/%s/%s.sha512" $dist $version $file)
+ ) -}}
+ {{- end -}}
+{{- end -}}
+{{- if not $rows -}}{{- errorf "%s: asf-downloads release %s has no artifacts" $position $version -}}{{- end -}}
+<section class="hg-asf-release" id="hg-dl-{{ replace $version "." "-" }}">
+ {{- if ne $mode "latest" }}
+ <h4 class="hg-asf-release__heading">{{ $version }}</h4>
+ {{- end }}
+ <ul class="hg-asf-release__meta">
+ <li><strong>{{ T "download_release_version" }}:</strong> {{ $version }}</li>
+ <li><strong>{{ T "download_release_date" }}:</strong> {{ $release.date }}</li>
+ <li><a href="{{ printf "/docs/changelog/hugegraph-%s-release-notes/" $version | relLangURL }}">{{ T "download_release_notes" }}</a></li>
+ </ul>
+ <div class="td-asset-list td-asset-list--static hg-asf-release__assets">
+ <div class="td-asset-list__table-wrap" tabindex="0" role="region" data-td-asset-table-scroll
+ aria-label="{{ T "ui_assets_download" }} · Apache HugeGraph {{ $version }}">
+ <table class="td-asset-list__table">
+ <caption class="visually-hidden">{{ T "ui_assets_download" }} · Apache HugeGraph {{ $version }}</caption>
+ <thead>
+ <tr>
+ <th scope="col">{{ T "download_table_component" }}</th>
+ <th scope="col">{{ T "download_table_type" }}</th>
+ <th scope="col">{{ T "download_table_mirror" }}</th>
+ <th scope="col">ASC</th>
+ <th scope="col">SHA512</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{- range $row := $rows }}
+ <tr>
+ <td>{{ $row.label }}</td>
+ <td>{{ T (printf "download_type_%s" $row.kind) }}</td>
+ <td class="td-asset-list__file">
+ <a href="{{ $row.mirror }}" dir="auto"
+ aria-label="{{ T "ui_assets_download" }}: {{ $row.file }}"><code>{{ $row.file }}</code></a>
+ </td>
+ <td><a href="{{ $row.asc }}" aria-label="ASC: {{ $row.file }}">ASC</a></td>
+ <td><a href="{{ $row.sha512 }}" aria-label="SHA512: {{ $row.file }}">SHA512</a></td>
+ </tr>
+ {{- end }}
+ </tbody>
+ </table>
+ </div>
+ </div>
+</section>
+{{- end -}}
+{{- if eq $mode "latest" }}
+<p class="hg-asf-release__note">{{ T "download_asf_note" }}</p>
+{{- end -}}
diff --git a/layouts/_partials/backlinks-sources.html b/layouts/_partials/backlinks-sources.html
new file mode 100644
index 000000000..f76a18b1f
--- /dev/null
+++ b/layouts/_partials/backlinks-sources.html
@@ -0,0 +1,21 @@
+{{- /* HugeGraph exposes backlinks only for latest documentation. Historical
+ builds and non-doc sections retain their original, uncluttered output. */ -}}
+{{- $page := . -}}
+{{- $enabled := and
+ (eq ($page.Site.Params.version | default "latest") "latest")
+ (eq $page.Section "docs")
+-}}
+{{- if isset $page.Params "backlinks" -}}
+ {{- $enabled = partial "validate.html" (dict
+ "value" (index $page.Params "backlinks") "kind" "bool"
+ "fallback" $enabled "key" "front matter backlinks"
+ "where" $page.Path) -}}
+{{- end -}}
+{{- $sources := slice -}}
+{{- if $enabled -}}
+ {{- $backlinkIndex := partialCached "backlinks-index.html" $page.Site $page.Site.Language.Lang -}}
+ {{- range sort (index $backlinkIndex $page.Path | default (slice)) "Path" -}}
+ {{- $sources = $sources | append . -}}
+ {{- end -}}
+{{- end -}}
+{{- return $sources -}}
diff --git a/layouts/_partials/backlinks.html b/layouts/_partials/backlinks.html
new file mode 100644
index 000000000..4c0917334
--- /dev/null
+++ b/layouts/_partials/backlinks.html
@@ -0,0 +1,50 @@
+{{- /* Keep the right rail compact: five backlinks remain immediately visible
+ and any additional sources use a native, keyboard-accessible disclosure.
+ Input: dict "page" . "sources" (from backlinks-sources.html). */ -}}
+{{- $p := .page -}}
+{{- $sources := .sources -}}
+{{- with $sources -}}
+{{- $shown := first 5 . -}}
+{{- $rest := after 5 . -}}
+<div class="td-shell-aside-group td-shell-backlinks">
+ <button type="button" class="td-shell-tree__row td-shell-aside-group__head"
+ data-td-shell-tree-toggle data-td-shell-aside-keep-open
+ aria-expanded="true" aria-controls="td-shell-backlinks-children"
+ aria-label="{{ T "ui_sidebar_collapse_section" }}: {{ T "ui_backlinks" }}"
+ data-td-label-expand="{{ T "ui_sidebar_expand_section" }}: {{ T "ui_backlinks" }}"
+ data-td-label-collapse="{{ T "ui_sidebar_collapse_section" }}: {{ T "ui_backlinks" }}">
+ <span class="td-shell-aside-group__icon" aria-hidden="true">
+ {{- partialCached "shell/icon.html" "link" "link" -}}
+ </span>
+ <span class="td-shell-aside-group__title">{{ T "ui_backlinks" }}</span>
+ <span class="td-shell-tree__chevron" aria-hidden="true">
+ {{- partialCached "shell/icon.html" "chevron-down" "chevron-down" -}}
+ </span>
+ </button>
+ <div id="td-shell-backlinks-children" class="td-shell-tree__children td-is-open">
+ <div class="td-shell-tree__children-inner">
+ <ul class="td-shell-backlinks__list">
+ {{- range $shown }}
+ <li class="td-shell-backlinks__item"><a href="{{ .RelPermalink }}"
+ {{- with .Description | plainify | strings.TrimSpace }} title="{{ . }}"{{ end }}>
+ {{- .LinkTitle | default .Title -}}
+ </a></li>
+ {{- end }}
+ </ul>
+ {{- with $rest }}
+ <details class="td-shell-backlinks__more">
+ <summary>{{ T "ui_backlinks_more" (len .) }}</summary>
+ <ul class="td-shell-backlinks__list">
+ {{- range . }}
+ <li class="td-shell-backlinks__item"><a href="{{ .RelPermalink }}"
+ {{- with .Description | plainify | strings.TrimSpace }} title="{{ . }}"{{ end }}>
+ {{- .LinkTitle | default .Title -}}
+ </a></li>
+ {{- end }}
+ </ul>
+ </details>
+ {{- end }}
+ </div>
+ </div>
+</div>
+{{- end -}}
diff --git a/layouts/_partials/community/members.html b/layouts/_partials/community/members.html
new file mode 100644
index 000000000..459604b1a
--- /dev/null
+++ b/layouts/_partials/community/members.html
@@ -0,0 +1,37 @@
+{{- $page := .page -}}
+{{- $data := hugo.Data.community.roster -}}
+{{- $labels := dict
+ "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from public ASF records." "chair" "Chair" "pmc" "PMC" "committers" "Committers")
+ "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 公开记录。" "chair" "主席" "pmc" "PMC" "committers" "Committers")
+-}}
+{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}}
+{{- $style := resources.Get "scss/community-members.scss" | toCSS | minify | fingerprint -}}
+<link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}" crossorigin="anonymous">
+<section id="project-members" class="td-landing-section hg-community-members" aria-labelledby="project-members-title">
+ <div class="td-site-container">
+ <div class="td-landing-section__header">
+ <h2 id="project-members-title">{{ $copy.title }}</h2>
+ <p>{{ $copy.lead }}</p>
+ </div>
+ {{- range $role := slice "pmc" "committers" }}
+ {{- $members := index $data.roles $role }}
+ <section class="hg-community-members__role" data-community-role="{{ $role }}" aria-labelledby="project-members-{{ $role }}">
+ <h3 id="project-members-{{ $role }}">{{ index $copy $role }}</h3>
+ <ul class="td-landing-contributor-grid hg-community-members__grid" role="list">
+ {{- range $members }}
+ <li class="td-landing-contributor hg-community-member">
+ <a class="hg-community-member__link" href="{{ .profile_url }}" aria-label="{{ .name }} ({{ .asf_id }})">
+ <span class="td-landing-contributor__avatar hg-community-member__avatar" aria-hidden="true">
+ <span class="hg-community-member__initials">{{ .initials }}</span>
+ {{- with .avatar }}<img src="{{ . }}" alt="" width="128" height="128" loading="lazy" decoding="async">{{ end }}
+ </span>
+ <span class="hg-community-member__identity">{{ with .github }}@{{ .login }}{{ else }}{{ .asf_id }}{{ end }}</span>
+ {{- if .chair }}<span class="hg-community-member__role-label">{{ $copy.chair }}</span>{{ end }}
+ </a>
+ </li>
+ {{- end }}
+ </ul>
+ </section>
+ {{- end }}
+ </div>
+</section>
diff --git a/layouts/_partials/community/members.md b/layouts/_partials/community/members.md
new file mode 100644
index 000000000..955aaee81
--- /dev/null
+++ b/layouts/_partials/community/members.md
@@ -0,0 +1,23 @@
+{{- $page := .page -}}
+{{- $data := hugo.Data.community.roster -}}
+{{- $labels := dict
+ "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from public ASF records." "chair" "Chair")
+ "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 公开记录。" "chair" "主席")
+-}}
+{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}}
+## {{ $copy.title }}
+
+{{ $copy.lead }}
+
+{{ range $role := slice "pmc" "committers" -}}
+### {{ if eq $role "pmc" }}PMC{{ else }}Committers{{ end }}
+
+{{ range (index $data.roles $role) -}}
+{{- $label := .asf_id -}}
+{{- with .github }}{{ $label = printf "@%s" .login }}{{ end -}}
+{{- $label = partial "content/markdown-escape.html" $label -}}
+{{- $url := partial "content/markdown-url.html" .profile_url -}}
+- [{{ $label }}]({{ $url }}){{ if .chair }} — {{ $copy.chair }}{{ end }}
+{{ end }}
+
+{{ end -}}
diff --git a/layouts/_partials/content/image-zoom-config.html b/layouts/_partials/content/image-zoom-config.html
new file mode 100644
index 000000000..d6421621b
--- /dev/null
+++ b/layouts/_partials/content/image-zoom-config.html
@@ -0,0 +1,9 @@
+{{- /* Docs and Blog use OINK's on-demand image preview in every version. */ -}}
+{{- $enabled := in (slice "docs" "blog") .Section -}}
+{{- if isset .Params "image_zoom" -}}
+ {{- $enabled = partial "validate.html" (dict
+ "value" (index .Params "image_zoom") "kind" "bool"
+ "fallback" $enabled "key" "front matter image_zoom"
+ "where" .Path) -}}
+{{- end -}}
+{{- return (dict "enable" $enabled) -}}
diff --git a/layouts/_partials/content/render.html b/layouts/_partials/content/render.html
new file mode 100644
index 000000000..77763cc28
--- /dev/null
+++ b/layouts/_partials/content/render.html
@@ -0,0 +1,5 @@
+{{- /* Mark the exact authored-content boundary for the publication scanner. */ -}}
+{{- $content := .Content -}}
+{{- partial "code/register-rendered-ids.html" (dict "page" . "content" $content) -}}
+{{- partial "content/register-derived.html" (dict "page" . "html" $content "raw" .RawContent) -}}
+{{- return (printf "<template data-hg-authored-content=\"start\"></template>%s<template data-hg-authored-content=\"end\"></template>" $content | safeHTML) -}}
diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html
new file mode 100644
index 000000000..c57acf63f
--- /dev/null
+++ b/layouts/_partials/hooks/body-end.html
@@ -0,0 +1,59 @@
+{{- $basePath := (urls.Parse .Site.BaseURL).Path | default "/" -}}
+{{- $localePrefix := cond (eq .Site.Language.Lang "cn") "cn/" "" -}}
+{{- $docsRoot := printf "%s%sdocs/" $basePath $localePrefix -}}
+{{- $fallbackMessage := T "ui_version_fallback" -}}
+{{- $shellConfig := dict
+ "version" (.Site.Params.version | default "latest")
+ "locale" .Site.Language.Lang
+ "docsRoot" $docsRoot
+ "versionFallbackMessage" $fallbackMessage
+-}}
+<script type="application/json" id="hg-shell-config">{{ $shellConfig | jsonify | safeJS }}</script>
+{{- $shell := resources.Get "js/hugegraph-shell.js" -}}
+{{- if hugo.IsProduction }}{{ $shell = $shell | minify | fingerprint }}{{ end }}
+<script src="{{ $shell.RelPermalink }}"
+ {{- with $shell.Data.Integrity }} integrity="{{ . }}" crossorigin="anonymous"{{ end }}></script>
+
+{{- $ai := partial "ai/config.html" . -}}
+{{- if $ai.enabled -}}
+ {{- $lang := .Site.Language.Lang -}}
+ {{- $sourceGroup := index $ai.sourceGroups $lang -}}
+ {{- $themeColor := index .Site.Params.ui "theme_color" -}}
+ {{- $historical := ne (.Site.Params.version | default "latest") "latest" -}}
+ {{- $labels := dict
+ "ask" (T "ui_ask_ai")
+ "description" (T "ui_ask_ai_description")
+ "latest" (T "ui_ask_ai_latest")
+ "retry" (T "ui_retry")
+ "error" (T "ui_ai_error")
+ -}}
+ {{- $clientConfig := dict
+ "websiteId" $ai.websiteID
+ "sourceGroupId" $sourceGroup
+ "locale" (cond (eq $lang "cn") "zh" "en")
+ "themeColor" $themeColor
+ "historical" $historical
+ "labels" $labels
+ -}}
+<script type="application/json" id="hg-ai-config">{{ $clientConfig | jsonify | safeJS }}</script>
+<button type="button" class="hg-ask-ai-launcher d-print-none" data-hg-ask-ai
+ data-hg-ai-submit="false" aria-haspopup="dialog"{{ if $historical }} aria-label="{{ index $labels "ask" }} — {{ index $labels "latest" }}"{{ end }}>
+ <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
+ <span>{{ index $labels "ask" }}</span>
+</button>
+<dialog class="hg-ai-consent" data-hg-ai-consent aria-labelledby="hg-ai-consent-title" aria-describedby="hg-ai-disclosure">
+ <h2 id="hg-ai-consent-title">{{ index $labels "ask" }}</h2>
+ <p id="hg-ai-disclosure">{{ T "ui_ai_consent_description" }}</p>
+ {{ if $historical }}<p>{{ index $labels "latest" }}.</p>{{ end }}
+ <p><a href="https://www.kapa.ai/content/privacy-policy" target="_blank" rel="noopener noreferrer">{{ T "ui_ai_privacy" }}</a></p>
+ <div class="hg-ai-consent-actions">
+ <button type="button" class="btn btn-secondary" data-hg-ai-cancel autofocus>{{ T "ui_ai_consent_cancel" }}</button>
+ <button type="button" class="btn btn-primary" data-hg-ai-continue>{{ T "ui_ai_consent_continue" }}</button>
+ </div>
+</dialog>
+<div class="hg-ai-status visually-hidden" role="status" aria-live="polite" data-hg-ai-status></div>
+ {{- $adapter := resources.Get "js/kapa-adapter.js" -}}
+ {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint }}{{ end }}
+<script src="{{ $adapter.RelPermalink }}"
+ {{- with $adapter.Data.Integrity }} integrity="{{ . }}" crossorigin="anonymous"{{ end }}></script>
+{{- end -}}
diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html
new file mode 100644
index 000000000..43baeb1fb
--- /dev/null
+++ b/layouts/_partials/hooks/head-end.html
@@ -0,0 +1,5 @@
+{{- $themeColor := index .Site.Params.ui "theme_color" | default "" -}}
+{{- if not (findRE `^#[0-9a-fA-F]{6}$` $themeColor) -}}
+ {{- errorf "params.ui.theme_color must be a six-digit hexadecimal color" -}}
+{{- end }}
+<style>:root { --hg-theme-color: {{ $themeColor | safeCSS }}; }</style>
diff --git a/layouts/_partials/landing/sections/community-members.html b/layouts/_partials/landing/sections/community-members.html
new file mode 100644
index 000000000..2e1abd08b
--- /dev/null
+++ b/layouts/_partials/landing/sections/community-members.html
@@ -0,0 +1 @@
+{{- partial "community/members.html" (dict "page" .page) -}}
diff --git a/layouts/_partials/navbar-item.html b/layouts/_partials/navbar-item.html
index a3f2dec9d..d07dd32d2 100644
--- a/layouts/_partials/navbar-item.html
+++ b/layouts/_partials/navbar-item.html
@@ -6,7 +6,6 @@
{{- $mode := .mode -}}
{{- $index := .index -}}
{{- $hasChildren := $entry.HasChildren -}}
-{{- $hasVersionLinks := and (eq $entry.Identifier "docs") (gt (len ($page.Site.Params.versions | default slice)) 0) -}}
{{- $taxonomyPage := false -}}
{{- with $entry.Page -}}
{{- if eq .Kind "taxonomy" }}{{ $taxonomyPage = . }}{{ end -}}
@@ -28,7 +27,7 @@
{{- end -}}
{{- end -}}
{{- end -}}
-{{- $hasPanel := or $hasChildren $taxonomyPage $hasVersionLinks -}}
+{{- $hasPanel := or $hasChildren $taxonomyPage -}}
{{- $key := $entry.Identifier | default $entry.Name | urlize -}}
{{- $panelID := printf "td-navbar-%s-%s-%d" $mode $key $index -}}
@@ -53,19 +52,6 @@
{{ partialCached "navbar-taxonomy-tags.html"
(dict "page" $page "taxonomyPage" $taxonomyPage)
$page.Site.Language.Lang $taxonomyPage.RelPermalink }}
- {{- else if $hasVersionLinks -}}
- {{- range $page.Site.Params.versions -}}
- {{- $url := strings.TrimSuffix "/" (.url | default "") -}}
- {{- if $url -}}
- {{- $isActive := eq .version $page.Site.Params.version -}}
- <a class="td-nav-menu__item-link{{ if $isActive }} active{{ end }}" href="{{ $url }}/"
- data-td-navbar-level="1" data-td-navbar-kind="version"
- {{- if $isActive }} aria-current="page"{{ end }}>
- <i class="fa-solid fa-code-branch" aria-hidden="true"></i>
- <span class="td-navbar-entry__label">{{ .name | default .version }}</span>
- </a>
- {{- end -}}
- {{- end -}}
{{- else -}}
{{ partial "navbar-group-items.html" (dict "page" $page "items" $entry.Children "mode" "desktop" "top" $entry "depth" 1) }}
{{- end }}
diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html
index bfcfca10e..9eeb35de0 100644
--- a/layouts/_partials/navbar.html
+++ b/layouts/_partials/navbar.html
@@ -53,6 +53,13 @@
{{- /* Right zone: the search box leads as the elastic boundary before
the fixed controls — version, language, theme, GitHub. */ -}}
<div class="td-nav-links td-nav-util-zone">
+ {{- if $drawerMode }}
+ <button class="td-nav-util hg-sidebar-restore" type="button" hidden
+ data-td-shell-sidebar-toggle title="{{ T "ui_sidebar_expand" }}"
+ aria-label="{{ T "ui_sidebar_expand" }}" aria-controls="td-shell-sidebar">
+ {{- partialCached "shell/icon.html" "panel-left" "sidebar-restore" -}}
+ </button>
+ {{- end }}
{{- if $localSearch }}
<button type="button" class="td-nav-search-box" data-td-shell-search-open
title="{{ T "ui_search" }}" aria-label="{{ T "ui_search" }}"
@@ -70,21 +77,7 @@
{{- partialCached "shell/icon.html" "code-branch" "navbar-version-code-branch" -}}
</button>
<div class="td-nav-hover-menu__pop" role="group" aria-label="{{ $menuName | plainify }}">
- {{- range . }}
- {{- if eq .name "---" }}
- <hr class="td-nav-hover-menu__divider">
- {{- else }}
- {{- $url := strings.TrimSuffix "/" (.url | default "") }}
- {{- $isActive := or (eq .version $.Site.Params.version) (eq (strings.TrimSuffix "/" $.Site.BaseURL) $url) }}
- {{- $path := cond (and $.Site.Params.version_menu_pagelinks (ne .pagelinks false)) $.RelPermalink "" }}
- {{- if $url }}
- <a class="td-nav-hover-menu__option{{ if $isActive }} td-is-active{{ end }}"
- {{- if $isActive }} aria-current="page"{{ end }} href="{{ $url }}{{ $path }}">{{ .name | default .version | markdownify }}</a>
- {{- else }}
- <span class="td-nav-hover-menu__option td-is-disabled">{{ .name | default .version | markdownify }}</span>
- {{- end }}
- {{- end }}
- {{- end }}
+ {{ partial "version-menu-links.html" (dict "page" $ "versions" . "class" "td-nav-hover-menu__option" "icon" false "key" "navbar") }}
</div>
</div>
{{- end }}
@@ -159,15 +152,7 @@
</div>
{{- else if and (eq .Identifier "docs") $.Site.Params.versions }}
<div class="td-site-drawer__children" aria-label="{{ $.Site.Params.version_menu | default "Version" }}">
- {{- range $.Site.Params.versions }}
- {{- $url := strings.TrimSuffix "/" (.url | default "") -}}
- {{- if $url }}
- <a class="td-site-nav__menu-link td-site-nav__menu-child" href="{{ $url }}/">
- <i class="fa-solid fa-code-branch" aria-hidden="true"></i>
- <span>{{ .name | default .version }}</span>
- </a>
- {{- end }}
- {{- end }}
+ {{ partial "version-menu-links.html" (dict "page" $ "versions" $.Site.Params.versions "class" "td-site-nav__menu-link td-site-nav__menu-child" "key" "landing-mobile") }}
</div>
{{- end }}
{{- end }}
diff --git a/layouts/_partials/print/page-content.html b/layouts/_partials/print/page-content.html
new file mode 100644
index 000000000..2234924f8
--- /dev/null
+++ b/layouts/_partials/print/page-content.html
@@ -0,0 +1,8 @@
+{{- $page := .page -}}
+{{- $page.Store.Set "tdOutputFormat" "print" -}}
+{{- if .book }}{{ $page.Store.Set "tdBookAggregate" true }}{{ end -}}
+{{- $content := $page.RenderString (dict "display" "block") $page.RawContent -}}
+{{- $content = partial "content/static-image-output.html" $content -}}
+{{- partial "code/register-rendered-ids.html" (dict "page" $page "content" $content) -}}
+{{- if .book }}{{ $page.Store.Delete "tdBookAggregate" }}{{ end -}}
+{{- return (printf "<template data-hg-authored-content=\"start\"></template>%s<template data-hg-authored-content=\"end\"></template>" $content | safeHTML) -}}
diff --git a/layouts/_partials/share/bar.html b/layouts/_partials/share/bar.html
new file mode 100644
index 000000000..39e0820fa
--- /dev/null
+++ b/layouts/_partials/share/bar.html
@@ -0,0 +1,13 @@
+{{- if and .IsPage (eq .Section "blog") -}}
+<section class="td-share d-print-none" data-td-share data-td-page-context
+ data-td-t-copied="{{ T "ui_copy_link_success" }}"
+ data-td-t-copy-error="{{ T "ui_copy_link_error" }}">
+ <div class="td-share__items" role="group" aria-label="{{ T "ui_share" }}">
+ <button type="button" class="td-share__item td-share__item--copy"
+ data-td-action="copy_link" data-td-action-url="{{ .Permalink }}">
+ <span class="td-share__icon"><i class="fa-solid fa-link" aria-hidden="true"></i></span>
+ <span>{{ T "ui_copy_link" }}</span>
+ </button>
+ </div>
+</section>
+{{- end -}}
diff --git a/layouts/_partials/shell/sidebar-panel.html b/layouts/_partials/shell/sidebar-panel.html
index ae8692525..e4a57af25 100644
--- a/layouts/_partials/shell/sidebar-panel.html
+++ b/layouts/_partials/shell/sidebar-panel.html
@@ -63,15 +63,7 @@
<section class="hg-shell-mobile-utils__group" aria-labelledby="hg-mobile-versions">
<strong id="hg-mobile-versions">{{ $.Site.Params.version_menu | default "Version" }}</strong>
<div class="hg-shell-mobile-utils__links">
- {{- range . }}
- {{- $url := strings.TrimSuffix "/" (.url | default "") -}}
- {{- if $url }}
- <a href="{{ $url }}/"{{ if eq .version $.Site.Params.version }} aria-current="page"{{ end }}>
- {{- partialCached "shell/icon.html" "code-branch" (printf "mobile-version-%s" .version) -}}
- <span>{{ .name | default .version }}</span>
- </a>
- {{- end }}
- {{- end }}
+ {{ partial "version-menu-links.html" (dict "page" $ "versions" . "class" "" "key" "mobile") }}
</div>
</section>
{{- end }}
diff --git a/layouts/_partials/version-link.html b/layouts/_partials/version-link.html
new file mode 100644
index 000000000..3b95544a8
--- /dev/null
+++ b/layouts/_partials/version-link.html
@@ -0,0 +1,15 @@
+{{- $p := .page -}}
+{{- $version := .version -}}
+{{- $versionID := $version.version | default ($version.name | urlize) -}}
+{{- $target := partial "version-target.html" (dict "page" $p "version" $version) -}}
+<a class="{{ .class }}"
+ data-hg-version-id="{{ $versionID }}"
+ data-hg-version-equivalent="{{ $target.equivalent }}"
+ data-hg-version-fallback="{{ $target.fallback }}"
+ {{- if .active }} aria-current="page"{{ end }}
+ href="{{ $target.url }}">
+ {{- if ne .icon false -}}
+ {{- partialCached "shell/icon.html" "code-branch" .iconKey -}}
+ {{- end -}}
+ <span>{{ $version.name | default $version.version | markdownify }}</span>
+</a>
diff --git a/layouts/_partials/version-menu-links.html b/layouts/_partials/version-menu-links.html
new file mode 100644
index 000000000..6f3bf8117
--- /dev/null
+++ b/layouts/_partials/version-menu-links.html
@@ -0,0 +1,17 @@
+{{- /* Presentation only: all version targets remain in the document. Native
+ details provides pointer, touch and keyboard access without a JS dependency. */ -}}
+{{- $p := .page -}}
+{{- $links := where .versions "url" "ne" "" -}}
+{{- range $index, $version := $links -}}
+ {{- if eq $index 3 }}
+ <details class="hg-version-overflow">
+ <summary aria-label="{{ T "ui_more_versions" }}" title="{{ T "ui_more_versions" }}">…</summary>
+ {{- end }}
+ {{- $active := eq $version.version $p.Site.Params.version -}}
+ {{- partial "version-link.html" (dict
+ "page" $p "version" $version "active" $active "icon" $.icon
+ "class" (printf "%s%s" $.class (cond $active " td-is-active" ""))
+ "iconKey" (printf "%s-version-%s" $.key $version.version)
+ ) -}}
+{{- end -}}
+{{- if gt (len $links) 3 }}</details>{{ end -}}
diff --git a/layouts/_partials/version-options.html b/layouts/_partials/version-options.html
new file mode 100644
index 000000000..e8c40362c
--- /dev/null
+++ b/layouts/_partials/version-options.html
@@ -0,0 +1,22 @@
+{{- $p := . -}}
+{{- $options := slice -}}
+{{- $baseURL := strings.TrimSuffix "/" $p.Site.BaseURL -}}
+{{- range $p.Site.Params.versions -}}
+ {{- if ne .name "---" -}}
+ {{- $versionID := .version | default (.name | urlize) -}}
+ {{- $rawURL := .url | default "" -}}
+ {{- $available := ne $rawURL "" -}}
+ {{- $target := partial "version-target.html" (dict "page" $p "version" .) -}}
+ {{- $options = $options | append (dict
+ "id" $versionID
+ "title" (.name | default .version | plainify)
+ "url" $target.url
+ "active" (or (eq .version $p.Site.Params.version) (eq $baseURL (strings.TrimSuffix "/" $rawURL)))
+ "available" $available
+ "disabledReason" (cond $available "" (printf "URL %s" (T "ui_field_required")))
+ "equivalent" $target.equivalent
+ "fallback" $target.fallback
+ ) -}}
+ {{- end -}}
+{{- end -}}
+{{- return $options -}}
diff --git a/layouts/_partials/version-target.html b/layouts/_partials/version-target.html
new file mode 100644
index 000000000..7a989bc5f
--- /dev/null
+++ b/layouts/_partials/version-target.html
@@ -0,0 +1,68 @@
+{{- $p := .page -}}
+{{- $version := .version -}}
+{{- $versionID := $version.version | default ($version.name | urlize) -}}
+{{- $rawURL := $version.url | default "" -}}
+{{- $versionURL := "" -}}
+{{- if $rawURL }}{{ $versionURL = printf "%s/" (strings.TrimSuffix "/" $rawURL) }}{{ end -}}
+{{- $target := $versionURL -}}
+{{- $equivalent := false -}}
+{{- $fallback := false -}}
+{{- $relative := strings.TrimPrefix "/" $p.RelPermalink -}}
+{{- $locale := $p.Site.Language.Lang -}}
+{{- $docsPrefix := cond (eq $locale "cn") "cn/docs/" "docs/" -}}
+{{- $docsSuffix := cond (eq $locale "cn") "/cn/docs" "/docs" -}}
+{{- $root := strings.TrimSuffix $docsSuffix (strings.TrimSuffix "/" $versionURL) -}}
+{{- if and $rawURL (strings.HasPrefix $relative $docsPrefix) -}}
+ {{- $pages := hugo.Data.version_routes.pages | default dict -}}
+ {{- $currentVersion := $p.Site.Params.version | default "latest" -}}
+ {{- $logicalID := $p.Store.Get "hgVersionLogicalID" -}}
+ {{- if eq $logicalID nil -}}
+ {{- $logicalID = "" -}}
+ {{- range $candidateID, $candidateRoutes := $pages -}}
+ {{- if eq (index $candidateRoutes $currentVersion) $relative -}}
+ {{- $logicalID = $candidateID -}}
+ {{- break -}}
+ {{- end -}}
+ {{- end -}}
+ {{- $p.Store.Set "hgVersionLogicalID" $logicalID -}}
+ {{- end -}}
+ {{- $routes := cond (ne $logicalID "") (index $pages $logicalID) nil -}}
+ {{- $path := false -}}
+ {{- with $routes -}}
+ {{- $path = index . $versionID -}}
+ {{- end -}}
+ {{- if and (not $path) (ne $logicalID "") -}}
+ {{- $alternates := slice -}}
+ {{- range hugo.Data.version_routes.equivalents | default (slice) -}}
+ {{- if in . $logicalID -}}
+ {{- range . -}}
+ {{- if ne . $logicalID -}}
+ {{- $alternateRoutes := index $pages . -}}
+ {{- with $alternateRoutes -}}
+ {{- $alternatePath := index . $versionID -}}
+ {{- with $alternatePath -}}
+ {{- $alternates = $alternates | append . -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if eq (len $alternates) 1 -}}
+ {{- $path = index $alternates 0 -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if $path -}}
+ {{- $target = printf "%s/%s" $root $path -}}
+ {{- $equivalent = true -}}
+ {{- else -}}
+ {{- $docsRoot := cond (eq $locale "cn") "cn/docs/" "docs/" -}}
+ {{- $target = printf "%s/%s#hg-version-fallback" $root $docsRoot -}}
+ {{- $fallback = true -}}
+ {{- end -}}
+{{- end -}}
+{{- return (dict
+ "url" $target
+ "equivalent" $equivalent
+ "fallback" $fallback
+) -}}
diff --git a/layouts/_shortcodes/asf-downloads.html b/layouts/_shortcodes/asf-downloads.html
new file mode 100644
index 000000000..f12b80048
--- /dev/null
+++ b/layouts/_shortcodes/asf-downloads.html
@@ -0,0 +1,8 @@
+{{- /* {{< asf-downloads latest >}} or {{< asf-downloads archived >}}:
+ ASF release download tables driven by data/downloads/asf.json. The
+ positional mode is required; a missing mode fails the build instead of
+ rendering duplicate sections. */ -}}
+{{- if or .IsNamedParams (ne (len .Params) 1) -}}
+ {{- errorf "asf-downloads requires exactly one positional mode (latest or archived) at %s" .Position -}}
+{{- end -}}
+{{- partial "asf-downloads.html" (dict "page" .Page "mode" (.Get 0) "position" .Position) -}}
diff --git a/layouts/community/landing.md b/layouts/community/landing.md
new file mode 100644
index 000000000..e884af8fe
--- /dev/null
+++ b/layouts/community/landing.md
@@ -0,0 +1,29 @@
+{{- /*
+ Community Markdown follows the landing data order. Native OINK sections use
+ its text renderer unchanged; only the nested roster uses the site partial.
+*/ -}}
+{{- .Store.Set "tdOutputFormat" "markdown" -}}
+# {{ .Title }}
+
+{{ with .Description }}
+> {{ . }}
+
+{{ end }}
+
+{{ $page := . -}}
+{{- $landing := partial "landing/data.html" . -}}
+{{- $chunks := slice -}}
+{{- range $entry := ($landing.sections | default slice) -}}
+ {{- $resolved := partial "landing/entry.html" (dict "home" $landing "entry" $entry) -}}
+ {{- if and $resolved.enabled $resolved.data -}}
+ {{- if eq $resolved.type "community-members" -}}
+ {{- $chunks = $chunks | append (partial "community/members.md" (dict "page" $page) | strings.TrimSpace) -}}
+ {{- else -}}
+ {{- $text := partial "landing/text.html" (dict "page" $page "data" (dict "sections" (slice $entry))) | strings.TrimSpace -}}
+ {{- with $text }}
+ {{- $chunks = $chunks | append . -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+{{- end -}}
+{{ delimit $chunks "\n\n" | safeHTML }}
diff --git a/layouts/landing.html b/layouts/landing.html
new file mode 100644
index 000000000..87dec7159
--- /dev/null
+++ b/layouts/landing.html
@@ -0,0 +1,6 @@
+{{ define "main" -}}
+<template data-hg-authored-content="start"></template>
+{{- $landing := partial "landing/data.html" . -}}
+{{- partial "landing/render.html" (dict "page" . "data" $landing) -}}
+<template data-hg-authored-content="end"></template>
+{{- end }}
diff --git a/layouts/landing.print.html b/layouts/landing.print.html
new file mode 100644
index 000000000..87dec7159
--- /dev/null
+++ b/layouts/landing.print.html
@@ -0,0 +1,6 @@
+{{ define "main" -}}
+<template data-hg-authored-content="start"></template>
+{{- $landing := partial "landing/data.html" . -}}
+{{- partial "landing/render.html" (dict "page" . "data" $landing) -}}
+<template data-hg-authored-content="end"></template>
+{{- end }}
diff --git a/scripts/community_roster.md b/scripts/community_roster.md
new file mode 100644
index 000000000..d317dbc72
--- /dev/null
+++ b/scripts/community_roster.md
@@ -0,0 +1,41 @@
+# Community roster data
+
+`roster.json` is the checked-in, visitor-facing snapshot of current Apache
+HugeGraph PMC members and Committers. It is generated from the three ASF public
+sources recorded in the file:
+
+```bash
+python3 scripts/community_roster.py refresh
+python3 scripts/community_roster.py validate --warn-after-days 90
+```
+
+`refresh` is a maintainer-run operation. It finishes all source, role, mapping,
+and avatar checks before atomically replacing the last-good roster. It never
+pushes or opens a pull request.
+
+`github-map.json` is deliberately maintained by human review. A mapping must
+record both the exact GitHub login and the account's numeric GitHub user ID.
+Do not derive mappings from a person's name, email address, employer, or commit
+history. Leave an ASF ID unmapped until a maintainer has confirmed the account.
+
+Mapped avatars are downloaded during refresh, converted with `cwebp` when
+needed, stripped of metadata, checked as 128 by 128 WebP, and stored under a
+SHA-256 content-addressed filename. Unmapped members render initials and link
+to the ASF phonebook without requiring JavaScript.
+
+The fixed `validate` command is fully offline and checks checked-in data,
+identity rules, and local avatar files. Render the site separately, then opt in
+to artifact checks:
+
+```bash
+hugo --destination /safe/prebuilt/site
+python3 scripts/community_roster.py validate \
+ --warn-after-days 90 \
+ --artifact /safe/prebuilt/site
+```
+
+The artifact option never invokes Hugo or downloads modules itself.
+
+Once the verified assets and roster are published, removal of an unreferenced
+old avatar is best effort. A cleanup failure emits an Actions warning but does
+not invalidate or roll back the complete new bundle.
diff --git a/scripts/community_roster.py b/scripts/community_roster.py
new file mode 100644
index 000000000..c72f885fe
--- /dev/null
+++ b/scripts/community_roster.py
@@ -0,0 +1,710 @@
+#!/usr/bin/env python3
+"""Refresh and validate the offline Apache HugeGraph community roster."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import hashlib
+import html.parser
+import json
+import os
+import pathlib
+import re
+import shutil
+import struct
+import subprocess
+import sys
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+DATA_DIR = ROOT / "data" / "community"
+ROSTER_PATH = DATA_DIR / "roster.json"
+MAP_PATH = DATA_DIR / "github-map.json"
+AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars"
+PROJECT = "hugegraph"
+SCHEMA_VERSION = 1
+SOURCES = {
+ "committee": "https://whimsy.apache.org/public/committee-info.json",
+ "projects": "https://whimsy.apache.org/public/public_ldap_projects.json",
+ "people": "https://whimsy.apache.org/public/public_ldap_people.json",
+}
+JSON_LIMIT = 16 * 1024 * 1024
+AVATAR_LIMIT = 5 * 1024 * 1024
+ASF_ID_PATTERN = re.compile(r"^[a-z][a-z0-9._-]*$")
+GITHUB_LOGIN_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
+AVATAR_PATH_PATTERN = re.compile(r"^/img/community/avatars/([0-9a-f]{64})\.webp$")
+
+
+class RosterError(ValueError):
+ pass
+
+
+def _read_json(path: pathlib.Path) -> dict:
+ try:
+ result = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise RosterError(f"{path}: invalid JSON: {exc}") from exc
+ if not isinstance(result, dict):
+ raise RosterError(f"{path}: JSON root must be an object")
+ return result
+
+
+def _validate_remote_url(url: str, expected_hosts: set[str], kind: str) -> None:
+ try:
+ parsed = urllib.parse.urlparse(url)
+ port = parsed.port
+ except ValueError as exc:
+ raise RosterError(f"{kind}: malformed URL: {url}") from exc
+ if (
+ parsed.scheme != "https"
+ or parsed.hostname not in expected_hosts
+ or port not in (None, 443)
+ or parsed.username is not None
+ or parsed.password is not None
+ ):
+ raise RosterError(f"{kind}: URL is not allowlisted: {url}")
+
+
+class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler):
+ def __init__(self, expected_hosts: set[str], kind: str):
+ super().__init__()
+ self.expected_hosts = expected_hosts
+ self.kind = kind
+
+ def redirect_request(self, request, fp, code, msg, headers, newurl):
+ _validate_remote_url(newurl, self.expected_hosts, self.kind)
+ return super().redirect_request(request, fp, code, msg, headers, newurl)
+
+
+def _open_allowlisted(request: urllib.request.Request, expected_hosts: set[str], kind: str):
+ _validate_remote_url(request.full_url, expected_hosts, kind)
+ opener = urllib.request.build_opener(_AllowlistedRedirectHandler(expected_hosts, kind))
+ return opener.open(request, timeout=30)
+
+
+def _read_bounded_response(response, *, expected_hosts: set[str], content_types: set[str], limit: int, kind: str) -> bytes:
+ final_url = response.geturl()
+ _validate_remote_url(final_url, expected_hosts, kind)
+ content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
+ if content_type not in content_types and not (kind == "JSON source" and content_type.endswith("+json")):
+ raise RosterError(f"{kind}: unsupported Content-Type {content_type!r}")
+ raw = response.read(limit + 1)
+ if len(raw) > limit:
+ raise RosterError(f"{kind}: response exceeds {limit} bytes")
+ return raw
+
+
+def _fetch_json(url: str) -> dict:
+ request = urllib.request.Request(url, headers={"User-Agent": "apache-hugegraph-doc-community-roster/1"})
+ with _open_allowlisted(request, {"whimsy.apache.org"}, "JSON source") as response:
+ if response.status != 200:
+ raise RosterError(f"{url}: HTTP {response.status}")
+ raw = _read_bounded_response(
+ response,
+ expected_hosts={"whimsy.apache.org"},
+ content_types={"application/json"},
+ limit=JSON_LIMIT,
+ kind="JSON source",
+ )
+ try:
+ result = json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise RosterError(f"{url}: malformed JSON: {exc}") from exc
+ if not isinstance(result, dict):
+ raise RosterError(f"{url}: JSON root must be an object")
+ return result
+
+
+def _person_name(people: dict, asf_id: str) -> str:
+ if not ASF_ID_PATTERN.fullmatch(asf_id):
+ raise RosterError(f"invalid ASF ID {asf_id!r}")
+ records = people.get("people")
+ if not isinstance(records, dict):
+ raise RosterError("people source must contain a people object")
+ record = records.get(asf_id)
+ name = record.get("name") if isinstance(record, dict) else None
+ if isinstance(name, list):
+ name = name[0] if name else ""
+ if not isinstance(name, str) or not name.strip():
+ raise RosterError(f"people source has no public name for ASF ID {asf_id!r}")
+ return name.strip()
+
+
+def _initials(name: str) -> str:
+ parts = [part for part in name.replace("-", " ").split() if part]
+ return "".join(part[0].upper() for part in parts[:2]) or "?"
+
+
+def _sort_key(asf_id: str, names: dict[str, str]) -> tuple[str, str]:
+ return names[asf_id].casefold(), asf_id.casefold()
+
+
+def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict:
+ if data.get("schema_version") != SCHEMA_VERSION:
+ raise RosterError("github-map.json: schema_version must be 1")
+ mappings = data.get("mappings")
+ if not isinstance(mappings, dict):
+ raise RosterError("github-map.json: mappings must be an object")
+ logins: set[str] = set()
+ user_ids: set[int] = set()
+ for asf_id, mapping in mappings.items():
+ if not ASF_ID_PATTERN.fullmatch(asf_id):
+ raise RosterError(f"github-map.json: invalid ASF ID {asf_id!r}")
+ if roster_ids is not None and asf_id not in roster_ids:
+ raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}")
+ if not isinstance(mapping, dict):
+ raise RosterError(f"github-map.json: mapping for {asf_id!r} must be an object")
+ login, user_id = mapping.get("login"), mapping.get("user_id")
+ if not isinstance(login, str) or not login.strip() or login != login.strip():
+ raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed login")
+ if not GITHUB_LOGIN_PATTERN.fullmatch(login) or "--" in login:
+ raise RosterError(f"github-map.json: invalid GitHub login {login!r}")
+ if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0:
+ raise RosterError(f"github-map.json: {asf_id!r} needs a positive numeric user_id")
+ if login.casefold() in logins:
+ raise RosterError(f"github-map.json: duplicate GitHub login {login!r}")
+ if user_id in user_ids:
+ raise RosterError(f"github-map.json: duplicate GitHub user_id {user_id}")
+ logins.add(login.casefold())
+ user_ids.add(user_id)
+ return mappings
+
+
+def _webp_dimensions(raw: bytes) -> tuple[int, int]:
+ if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP":
+ raise RosterError("avatar is not a WebP image")
+ chunk = raw[12:16]
+ if chunk == b"VP8X":
+ return 1 + int.from_bytes(raw[24:27], "little"), 1 + int.from_bytes(raw[27:30], "little")
+ if chunk == b"VP8L":
+ bits = int.from_bytes(raw[21:25], "little")
+ return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)
+ if chunk == b"VP8 ":
+ marker = raw.find(b"\x9d\x01\x2a", 20)
+ if marker < 0 or marker + 7 > len(raw):
+ raise RosterError("avatar has an invalid VP8 frame")
+ width, height = struct.unpack_from("<HH", raw, marker + 3)
+ return width & 0x3FFF, height & 0x3FFF
+ raise RosterError(f"avatar uses unsupported WebP chunk {chunk!r}")
+
+
+def _webp_chunk_kinds(raw: bytes) -> list[bytes]:
+ if len(raw) < 20 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP":
+ raise RosterError("avatar is not a WebP image")
+ if int.from_bytes(raw[4:8], "little") != len(raw) - 8:
+ raise RosterError("avatar has an invalid RIFF length")
+ kinds: list[bytes] = []
+ cursor = 12
+ while cursor + 8 <= len(raw):
+ kind = raw[cursor : cursor + 4]
+ size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little")
+ cursor += 8 + size + (size % 2)
+ if cursor > len(raw):
+ raise RosterError("avatar has a truncated WebP chunk")
+ kinds.append(kind)
+ if cursor != len(raw):
+ raise RosterError("avatar has trailing WebP data")
+ return kinds
+
+
+def _validate_webp(raw: bytes, expected_dimensions: tuple[int, int] | None = None) -> tuple[int, int]:
+ dimensions = _webp_dimensions(raw)
+ kinds = _webp_chunk_kinds(raw)
+ if not any(kind in {b"VP8 ", b"VP8L"} for kind in kinds):
+ raise RosterError("avatar has no decodable WebP image bitstream")
+ if any(kind in {b"EXIF", b"XMP ", b"ICCP"} for kind in kinds):
+ raise RosterError("avatar contains metadata")
+ if expected_dimensions and dimensions != expected_dimensions:
+ raise RosterError(f"avatar dimensions are {dimensions}, expected {expected_dimensions}")
+ decoder = shutil.which("dwebp")
+ if not decoder:
+ raise RosterError("validating mapped avatars requires dwebp")
+ with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-decode-") as work:
+ source = pathlib.Path(work) / "avatar.webp"
+ target = pathlib.Path(work) / "avatar.ppm"
+ source.write_bytes(raw)
+ try:
+ result = subprocess.run(
+ [decoder, str(source), "-o", str(target)],
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise RosterError(f"dwebp could not decode avatar: {exc}") from exc
+ if result.returncode or not target.is_file():
+ raise RosterError(f"dwebp rejected avatar: {result.stderr.strip()}")
+ return dimensions
+
+
+def _strip_webp_metadata(raw: bytes) -> bytes:
+ """Remove optional metadata chunks while preserving the image bitstream."""
+ _webp_dimensions(raw)
+ chunks: list[bytes] = []
+ cursor = 12
+ while cursor + 8 <= len(raw):
+ kind = raw[cursor : cursor + 4]
+ size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little")
+ end = cursor + 8 + size + (size % 2)
+ if end > len(raw):
+ raise RosterError("avatar has a truncated WebP chunk")
+ chunk = bytearray(raw[cursor:end])
+ if kind not in {b"EXIF", b"XMP ", b"ICCP"}:
+ if kind == b"VP8X":
+ chunk[8] &= ~0x2D
+ chunks.append(bytes(chunk))
+ cursor = end
+ if cursor != len(raw):
+ raise RosterError("avatar has trailing WebP data")
+ payload = b"WEBP" + b"".join(chunks)
+ return b"RIFF" + len(payload).to_bytes(4, "little") + payload
+
+
+def _avatar_bytes(user_id: int) -> bytes:
+ request = urllib.request.Request(
+ f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4",
+ headers={"Accept": "image/webp", "User-Agent": "apache-hugegraph-doc-community-roster/1"},
+ )
+ with _open_allowlisted(request, {"avatars.githubusercontent.com"}, "GitHub avatar") as response:
+ raw = _read_bounded_response(
+ response,
+ expected_hosts={"avatars.githubusercontent.com"},
+ content_types={"image/png", "image/jpeg", "image/webp"},
+ limit=AVATAR_LIMIT,
+ kind="GitHub avatar",
+ )
+ try:
+ raw = _strip_webp_metadata(raw)
+ except RosterError:
+ converter = shutil.which("cwebp")
+ if not converter:
+ raise RosterError("mapped avatars require cwebp when GitHub does not return WebP")
+ with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-") as work:
+ source = pathlib.Path(work) / "source"
+ target = pathlib.Path(work) / "avatar.webp"
+ source.write_bytes(raw)
+ try:
+ result = subprocess.run(
+ [converter, "-quiet", "-resize", "128", "128", "-metadata", "none", str(source), "-o", str(target)],
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {exc}") from exc
+ if result.returncode:
+ raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {result.stderr.strip()}")
+ raw = _strip_webp_metadata(target.read_bytes())
+ _validate_webp(raw, expected_dimensions=(128, 128))
+ return raw
+
+
+def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict:
+ member = {
+ "asf_id": asf_id,
+ "name": name,
+ "initials": _initials(name),
+ "chair": chair,
+ "profile_url": f"https://people.apache.org/phonebook.html?uid={asf_id}",
+ }
+ if mapping:
+ member["github"] = {"login": mapping["login"], "user_id": mapping["user_id"]}
+ return member
+
+
+def build_roster(committee_data: dict, projects_data: dict, people_data: dict, mapping_data: dict) -> dict:
+ projects = projects_data.get("projects")
+ committees = committee_data.get("committees")
+ if not isinstance(projects, dict) or not isinstance(committees, dict):
+ raise RosterError("ASF sources must contain projects and committees objects")
+ project = projects.get(PROJECT)
+ committee = committees.get(PROJECT)
+ if not isinstance(project, dict) or not isinstance(committee, dict):
+ raise RosterError("ASF sources do not contain the HugeGraph project")
+ owners, members = project.get("owners"), project.get("members")
+ chair_map, committee_roster = committee.get("chair"), committee.get("roster")
+ if not isinstance(owners, list) or not isinstance(members, list):
+ raise RosterError("LDAP project owners/members must be arrays")
+ if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in owners + members):
+ raise RosterError("LDAP project owners/members contain an invalid ASF ID")
+ for field, asf_ids in (("owners", owners), ("members", members)):
+ if len(asf_ids) != len(set(asf_ids)):
+ raise RosterError(f"LDAP project {field} contains duplicate ASF IDs")
+ if not isinstance(chair_map, dict) or len(chair_map) != 1:
+ raise RosterError("committee source must name exactly one Chair")
+ if not isinstance(committee_roster, dict):
+ raise RosterError("committee roster must be an object")
+ if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in [*chair_map, *committee_roster]):
+ raise RosterError("committee source contains an invalid ASF ID")
+ owner_ids, member_ids = set(owners), set(members)
+ chair = next(iter(chair_map))
+ if not owner_ids <= member_ids:
+ raise RosterError("LDAP owners must be a subset of members")
+ if chair not in owner_ids:
+ raise RosterError("Chair must be an LDAP owner")
+ if owner_ids != set(committee_roster):
+ raise RosterError("committee roster and LDAP owners disagree")
+ mappings = _validate_mapping(mapping_data, member_ids)
+ names = {asf_id: _person_name(people_data, asf_id) for asf_id in member_ids}
+ pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: _sort_key(item, names))
+ committer_ids = sorted(member_ids - owner_ids, key=lambda item: _sort_key(item, names))
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "project": PROJECT,
+ "retrieved_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
+ "source": {
+ **SOURCES,
+ "chair": chair,
+ "owners": sorted(owner_ids),
+ "members": sorted(member_ids),
+ },
+ "roles": {
+ "pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in pmc_ids],
+ "committers": [_member(i, names[i], False, mappings.get(i)) for i in committer_ids],
+ },
+ }
+
+
+def _install_avatars(candidate: dict, target: pathlib.Path) -> None:
+ target.mkdir(parents=True, exist_ok=True)
+ for role in ("pmc", "committers"):
+ for member in candidate["roles"][role]:
+ github = member.get("github")
+ if not github:
+ continue
+ raw = _avatar_bytes(github["user_id"])
+ digest = hashlib.sha256(raw).hexdigest()
+ path = target / f"{digest}.webp"
+ if not path.exists():
+ path.write_bytes(raw)
+ member["avatar"] = f"/img/community/avatars/{path.name}"
+ member["profile_url"] = f"https://github.com/{github['login']}"
+
+
+def validate_bundle(warn_after_days: int) -> list[str]:
+ _validate_repo_paths()
+ roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH)
+ if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") != PROJECT:
+ raise RosterError("roster.json: unsupported schema_version or project")
+ roles, source = roster.get("roles"), roster.get("source")
+ if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}:
+ raise RosterError("roster.json: roles must contain only pmc and committers")
+ if not isinstance(source, dict):
+ raise RosterError("roster.json: source must be an object")
+ for key, url in SOURCES.items():
+ if source.get(key) != url:
+ raise RosterError(f"roster.json: source.{key} is not authoritative")
+ owners, members, chair = source.get("owners"), source.get("members"), source.get("chair")
+ if not isinstance(owners, list) or not isinstance(members, list):
+ raise RosterError("roster.json: source owners/members must be arrays")
+ if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in owners + members):
+ raise RosterError("roster.json: source owners/members contain an invalid ASF ID")
+ if owners != sorted(set(owners)) or members != sorted(set(members)):
+ raise RosterError("roster.json: source owners/members must be sorted and unique")
+ if not set(owners) <= set(members) or chair not in owners:
+ raise RosterError("roster.json: invalid owners/members/Chair relationship")
+ if not isinstance(chair, str) or not ASF_ID_PATTERN.fullmatch(chair):
+ raise RosterError("roster.json: source Chair must be a valid ASF ID")
+ pmc, committers = roles["pmc"], roles["committers"]
+ if not isinstance(pmc, list) or not isinstance(committers, list) or not pmc:
+ raise RosterError("roster.json: invalid role arrays")
+ people = pmc + committers
+ for person in people:
+ if not isinstance(person, dict):
+ raise RosterError("roster.json: every role entry must be an object")
+ asf_id = person.get("asf_id")
+ if not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id):
+ raise RosterError("roster.json: role entry has an invalid ASF ID")
+ name = person.get("name")
+ if not isinstance(name, str) or not name.strip():
+ raise RosterError(f"roster.json: member name must be non-empty for {asf_id!r}")
+ if person.get("initials") != _initials(name):
+ raise RosterError(f"roster.json: member initials mismatch for {asf_id!r}")
+ if not isinstance(person.get("profile_url"), str):
+ raise RosterError(f"roster.json: member profile URL must be a string for {asf_id!r}")
+ if type(person.get("chair")) is not bool:
+ raise RosterError(f"roster.json: chair must be boolean for {asf_id!r}")
+ ids = [person["asf_id"] for person in people]
+ if len(ids) != len(set(ids)) or set(ids) != set(members):
+ raise RosterError("roster.json: members must match unique source ASF IDs")
+ if {p["asf_id"] for p in pmc} != set(owners):
+ raise RosterError("roster.json: PMC must equal owners")
+ if {p["asf_id"] for p in committers} != set(members) - set(owners):
+ raise RosterError("roster.json: Committers must equal members minus owners")
+ chairs = [person for person in people if person.get("chair") is True]
+ if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != chairs[0]:
+ raise RosterError("roster.json: unique Chair must be first in PMC")
+ for role, entries in roles.items():
+ tail = entries[1:] if role == "pmc" else entries
+ actual_order = [(p["name"].casefold(), p["asf_id"].casefold()) for p in tail]
+ if actual_order != sorted(actual_order):
+ raise RosterError(f"roster.json: {role} must be sorted by public name and ASF ID casefold")
+ mappings = _validate_mapping(mapping, set(ids))
+ for person in people:
+ expected, avatar = mappings.get(person["asf_id"]), person.get("avatar")
+ if expected != person.get("github"):
+ raise RosterError(f"roster.json: GitHub mapping drift for {person['asf_id']!r}")
+ if expected:
+ match = AVATAR_PATH_PATTERN.fullmatch(avatar) if isinstance(avatar, str) else None
+ if not match:
+ raise RosterError(f"roster.json: mapped member {person['asf_id']!r} needs a local avatar")
+ filename = f"{match.group(1)}.webp"
+ path = AVATAR_DIR / filename
+ if path.is_symlink():
+ raise RosterError(f"roster.json: avatar must not be a symlink {avatar}")
+ raw = path.read_bytes()
+ if hashlib.sha256(raw).hexdigest() != match.group(1):
+ raise RosterError(f"roster.json: invalid avatar {avatar}")
+ _validate_webp(raw, expected_dimensions=(128, 128))
+ if person["profile_url"] != f"https://github.com/{expected['login']}":
+ raise RosterError(f"roster.json: mapped profile URL mismatch")
+ elif avatar:
+ raise RosterError(f"roster.json: unmapped member has an avatar")
+ elif person["profile_url"] != f"https://people.apache.org/phonebook.html?uid={person['asf_id']}":
+ raise RosterError(f"roster.json: unmapped profile URL mismatch for {person['asf_id']!r}")
+ retrieved_at = roster.get("retrieved_at")
+ if not isinstance(retrieved_at, str):
+ raise RosterError("roster.json: retrieved_at must be an ISO-8601 UTC string")
+ try:
+ retrieved = dt.datetime.fromisoformat(retrieved_at.replace("Z", "+00:00"))
+ except (KeyError, TypeError, ValueError) as exc:
+ raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") from exc
+ now = dt.datetime.now(dt.timezone.utc)
+ if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5):
+ raise RosterError("roster.json: retrieved_at is in the future or lacks a timezone")
+ age = now - retrieved
+ return [f"community roster is {age.days} days old (threshold: {warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else []
+
+
+class _CommunityLinkParser(html.parser.HTMLParser):
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.role_stack: list[str | None] = []
+ self.section_order: list[str] = []
+ self.links = {"pmc": [], "committers": []}
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ attributes = dict(attrs)
+ if tag == "section":
+ role = attributes.get("data-community-role")
+ if role in self.links:
+ self.section_order.append(role)
+ self.role_stack.append(role if role in self.links else None)
+ elif tag == "a" and self.role_stack and self.role_stack[-1]:
+ href = attributes.get("href")
+ if href:
+ self.links[self.role_stack[-1]].append(href)
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "section" and self.role_stack:
+ self.role_stack.pop()
+
+
+def _rendered_role_links(rendered: str, html_output: bool) -> dict[str, list[str]]:
+ if html_output:
+ parser = _CommunityLinkParser()
+ parser.feed(rendered)
+ if parser.section_order != ["pmc", "committers"]:
+ raise RosterError("Community role section order drift")
+ return parser.links
+ starts = {}
+ for role, heading in (("pmc", "PMC"), ("committers", "Committers")):
+ match = re.search(rf"(?m)^### {heading}\s*$", rendered)
+ if not match:
+ return {"pmc": [], "committers": []}
+ starts[role] = match.end()
+ if starts["pmc"] >= starts["committers"]:
+ return {"pmc": [], "committers": []}
+ segments = {
+ "pmc": rendered[starts["pmc"] : starts["committers"]],
+ "committers": rendered[starts["committers"] :],
+ }
+ return {
+ role: re.findall(r"(?m)^-\s+\[[^\]]+\]\(([^)\s]+)\)", segment)
+ for role, segment in segments.items()
+ }
+
+
+def validate_rendered_outputs(destination: pathlib.Path) -> None:
+ expected = {
+ "community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
+ "_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
+ "community/index.md": ("## Project members", "### PMC", "### Committers"),
+ "cn/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
+ "cn/_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
+ "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"),
+ }
+ roster_roles = _read_json(ROSTER_PATH)["roles"]
+ for relative, markers in expected.items():
+ path = destination / relative
+ if not path.is_file():
+ raise RosterError(f"rendered output is missing {relative}")
+ rendered = path.read_text(encoding="utf-8")
+ if relative.endswith(".html"):
+ has_markers = all(
+ re.search(rf'data-community-role=(?:"{role}"|{role})(?:\s|>)', rendered)
+ for role in ("pmc", "committers")
+ )
+ else:
+ has_markers = all(marker in rendered for marker in markers)
+ if not has_markers:
+ raise RosterError(f"rendered output {relative} is missing Community markers")
+ rendered_links = _rendered_role_links(rendered, relative.endswith(".html"))
+ for role, entries in roster_roles.items():
+ expected_links = [person["profile_url"] for person in entries]
+ if rendered_links[role] != expected_links:
+ raise RosterError(f"rendered output {relative} has {role} link parity drift")
+
+
+def _atomic_write(path: pathlib.Path, raw: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
+ temporary_path = pathlib.Path(temporary)
+ try:
+ with os.fdopen(descriptor, "wb") as stream:
+ stream.write(raw)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary_path, path)
+ finally:
+ if temporary_path.exists():
+ temporary_path.unlink()
+
+
+def _unlink(path: pathlib.Path) -> None:
+ path.unlink()
+
+
+def _copy_candidate(raw: bytes, destination: pathlib.Path) -> None:
+ with destination.open("xb") as stream:
+ stream.write(raw)
+ stream.flush()
+ os.fsync(stream.fileno())
+
+
+def _validate_avatar_blob(name: str, raw: bytes) -> None:
+ match = re.fullmatch(r"([0-9a-f]{64})\.webp", name)
+ if not match or hashlib.sha256(raw).hexdigest() != match.group(1):
+ raise RosterError(f"candidate avatar name/hash mismatch: {name}")
+ _validate_webp(raw, expected_dimensions=(128, 128))
+
+
+def _assert_repo_path(path: pathlib.Path, label: str) -> None:
+ root = ROOT.absolute()
+ candidate = path.absolute()
+ try:
+ relative = candidate.relative_to(root)
+ except ValueError as exc:
+ raise RosterError(f"{label} must stay inside the repository") from exc
+ current = root
+ if current.is_symlink():
+ raise RosterError("repository root must not be a symlink")
+ for part in relative.parts:
+ current /= part
+ if current.is_symlink():
+ raise RosterError(f"{label} must not contain symlink path components")
+ try:
+ candidate.resolve(strict=False).relative_to(root.resolve(strict=True))
+ except (OSError, ValueError) as exc:
+ raise RosterError(f"{label} resolves outside the repository") from exc
+
+
+def _validate_repo_paths() -> None:
+ _assert_repo_path(DATA_DIR, "community data directory")
+ _assert_repo_path(ROSTER_PATH, "community roster")
+ _assert_repo_path(MAP_PATH, "GitHub mapping")
+ _assert_repo_path(AVATAR_DIR, "community avatar directory")
+
+
+def _commit_bundle(candidate: dict, candidate_avatars: dict[str, bytes]) -> None:
+ """Install verified immutable assets, then atomically publish the roster."""
+ _validate_repo_paths()
+ AVATAR_DIR.mkdir(parents=True, exist_ok=True)
+ referenced = {
+ pathlib.PurePosixPath(person["avatar"]).name
+ for role in candidate["roles"].values()
+ for person in role
+ if person.get("avatar")
+ }
+ existing = {path.name: path for path in AVATAR_DIR.glob("*.webp")}
+ for name, avatar in sorted(candidate_avatars.items()):
+ _validate_avatar_blob(name, avatar)
+ destination = AVATAR_DIR / name
+ if destination.exists() or destination.is_symlink():
+ try:
+ if destination.is_symlink():
+ raise RosterError(f"candidate destination is a symlink: {destination}")
+ _validate_avatar_blob(name, destination.read_bytes())
+ continue
+ except RosterError:
+ pass
+ staged = AVATAR_DIR / f".{name}.candidate"
+ try:
+ _copy_candidate(avatar, staged)
+ os.replace(staged, destination)
+ finally:
+ if staged.exists():
+ _unlink(staged)
+ raw = (json.dumps(candidate, indent=2, ensure_ascii=False) + "\n").encode()
+ # This atomic replace is the commit point. Failures before it leave the
+ # last-good roster selected; installed content-addressed assets are safe
+ # unreferenced candidates.
+ _atomic_write(ROSTER_PATH, raw)
+ for name in sorted(set(existing) - referenced):
+ try:
+ _unlink(AVATAR_DIR / name)
+ except OSError as exc:
+ print(
+ f"::warning file=static/img/community/avatars/{name}::"
+ f"could not remove unreferenced avatar: {exc}",
+ file=sys.stderr,
+ )
+
+
+def refresh() -> None:
+ _validate_repo_paths()
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ source_data = {key: _fetch_json(url) for key, url in SOURCES.items()}
+ candidate = build_roster(source_data["committee"], source_data["projects"], source_data["people"], _read_json(MAP_PATH))
+ work = pathlib.Path(tempfile.mkdtemp(prefix=".community-refresh-", dir=DATA_DIR))
+ try:
+ candidate_avatars = work / "avatars"
+ _install_avatars(candidate, candidate_avatars)
+ avatar_bytes = {path.name: path.read_bytes() for path in candidate_avatars.glob("*.webp")}
+ finally:
+ # Candidate cleanup is deliberately completed before the checked-in
+ # bundle changes, so cleanup failure cannot publish a new roster.
+ shutil.rmtree(work)
+ _commit_bundle(candidate, avatar_bytes)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+ commands.add_parser("refresh")
+ validate = commands.add_parser("validate")
+ validate.add_argument("--warn-after-days", type=int, default=90)
+ validate.add_argument("--artifact", type=pathlib.Path, help="validate a prebuilt Hugo artifact")
+ args = parser.parse_args()
+ try:
+ if args.command == "refresh":
+ refresh()
+ else:
+ if args.warn_after_days < 0:
+ raise RosterError("--warn-after-days must be non-negative")
+ for warning in validate_bundle(args.warn_after_days):
+ print(f"::warning file=data/community/roster.json::{warning}")
+ if args.artifact:
+ validate_rendered_outputs(args.artifact.resolve())
+ except (OSError, RosterError, urllib.error.URLError) as exc:
+ print(f"community roster: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/fixtures/community_search_queries.json b/scripts/fixtures/community_search_queries.json
new file mode 100644
index 000000000..a65cfdcb3
--- /dev/null
+++ b/scripts/fixtures/community_search_queries.json
@@ -0,0 +1,26 @@
+[
+ {"locale": "en", "query": "HugeGraph overview", "expected_ref": "/docs/introduction/"},
+ {"locale": "en", "query": "server quickstart", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-server/"},
+ {"locale": "en", "query": "distributed storage", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-hstore/"},
+ {"locale": "en", "query": "placement driver", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-pd/"},
+ {"locale": "en", "query": "graph computing", "expected_ref": "/docs/quickstart/computing/hugegraph-computer/"},
+ {"locale": "en", "query": "bulk import", "expected_ref": "/docs/quickstart/toolchain/hugegraph-loader/"},
+ {"locale": "en", "query": "graph visualization", "expected_ref": "/docs/quickstart/toolchain/hugegraph-hubble/"},
+ {"locale": "en", "query": "Java client", "expected_ref": "/docs/clients/"},
+ {"locale": "en", "query": "HugeGraph REST API", "expected_ref": "/docs/clients/restful-api/"},
+ {"locale": "en", "query": "server config", "expected_ref": "/docs/config/config-guide/"},
+ {"locale": "en", "query": "StandardAuthenticator", "expected_ref": "/docs/config/config-authentication/"},
+ {"locale": "en", "query": "release artifacts", "expected_ref": "/docs/download/download/"},
+ {"locale": "cn", "query": "HugeGraph 介绍", "expected_ref": "/cn/docs/introduction/"},
+ {"locale": "cn", "query": "Server 快速开始", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-server/"},
+ {"locale": "cn", "query": "分布式存储", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-hstore/"},
+ {"locale": "cn", "query": "元数据管理", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-pd/"},
+ {"locale": "cn", "query": "图计算", "expected_ref": "/cn/docs/quickstart/computing/hugegraph-computer/"},
+ {"locale": "cn", "query": "批量导入", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-loader/"},
+ {"locale": "cn", "query": "图可视化", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-hubble/"},
+ {"locale": "cn", "query": "Java 客户端", "expected_ref": "/cn/docs/clients/"},
+ {"locale": "cn", "query": "HugeGraph REST API", "expected_ref": "/cn/docs/clients/restful-api/"},
+ {"locale": "cn", "query": "Server 配置", "expected_ref": "/cn/docs/config/config-guide/"},
+ {"locale": "cn", "query": "权限配置", "expected_ref": "/cn/docs/config/config-authentication/"},
+ {"locale": "cn", "query": "发布包", "expected_ref": "/cn/docs/download/download/"}
+]
diff --git a/scripts/hugo.sh b/scripts/hugo.sh
new file mode 100755
index 000000000..6b1b4c8ed
--- /dev/null
+++ b/scripts/hugo.sh
@@ -0,0 +1,111 @@
+#!/bin/sh
+set -eu
+
+usage() {
+ printf '%s\n' \
+ "Usage: scripts/hugo.sh server [Hugo arguments...]" \
+ " scripts/hugo.sh build [Hugo arguments...]"
+}
+
+if [ "$#" -eq 0 ]; then
+ usage >&2
+ exit 2
+fi
+
+mode=$1
+shift
+case "$mode" in
+ server|build) ;;
+ *)
+ usage >&2
+ exit 2
+ ;;
+esac
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+repo_dir=$(dirname "$script_dir")
+cd "$repo_dir"
+
+# Sniff the documented origin/port spellings so the generated version config
+# stays aligned with the origin Hugo itself will use. Every argument is still
+# forwarded to Hugo unchanged; pflag's last-wins handling resolves overrides.
+port=
+base_url=
+previous=
+for argument in "$@"; do
+ case "$previous" in
+ --baseURL|-b) base_url=$argument ;;
+ --port|-p) port=$argument ;;
+ esac
+ case "$argument" in
+ --baseURL=*) base_url=${argument#*=} ;;
+ -b=*) base_url=${argument#-b=} ;;
+ -b?*) base_url=${argument#-b} ;;
+ --port=*) port=${argument#*=} ;;
+ -p=*) port=${argument#-p=} ;;
+ -p?*) port=${argument#-p} ;;
+ esac
+ previous=$argument
+done
+
+if [ -n "$base_url" ]; then
+ site_origin=$base_url
+elif [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then
+ site_origin=$HG_DOC_SITE_ORIGIN
+elif [ "$mode" = "server" ]; then
+ site_origin="http://localhost:${port:-1313}/"
+else
+ site_origin="https://hugegraph.apache.org/"
+fi
+case "$site_origin" in
+ http://*|https://*) ;;
+ *) printf 'scripts/hugo.sh: invalid site origin: %s\n' "$site_origin" >&2; exit 2 ;;
+esac
+
+temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/hugegraph-hugo.XXXXXX")
+config_file=$temp_dir/version-config.json
+cleanup() {
+ if [ -d "$temp_dir" ]; then
+ rm -f -- "$config_file"
+ rmdir -- "$temp_dir"
+ fi
+}
+trap cleanup 0 HUP INT TERM
+
+python_bin=${PYTHON_BIN:-python3}
+hugo_bin=${HUGO_BIN:-hugo}
+(
+ set -- scripts/versioning.py config \
+ --site-origin "$site_origin" \
+ --output "$config_file"
+ if [ -n "${HG_DOC_VERSION:-}" ]; then
+ set -- "$@" --version "$HG_DOC_VERSION"
+ fi
+ if [ -n "${HG_DOC_HISTORICAL_ORIGIN:-}" ]; then
+ set -- "$@" --historical-origin "$HG_DOC_HISTORICAL_ORIGIN"
+ fi
+ exec "$python_bin" "$@"
+)
+
+if [ "$mode" = "server" ]; then
+ if [ -n "$base_url" ] || [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then
+ "$hugo_bin" server \
+ --config "hugo.yaml,$config_file" \
+ --appendPort=false \
+ "$@"
+ else
+ "$hugo_bin" server --config "hugo.yaml,$config_file" "$@"
+ fi
+else
+ "$hugo_bin" \
+ --config "hugo.yaml,$config_file" \
+ --cleanDestinationDir \
+ --gc \
+ --minify \
+ --environment production \
+ --printPathWarnings \
+ --printI18nWarnings \
+ --panicOnWarning \
+ --logLevel info \
+ "$@"
+fi
diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py
new file mode 100644
index 000000000..c50c0fcc7
--- /dev/null
+++ b/scripts/test_community_roster.py
@@ -0,0 +1,785 @@
+import hashlib
+import importlib.util
+import json
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / "scripts" / "community_roster.py")
+roster = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader
+SPEC.loader.exec_module(roster)
+
+
+class FakeResponse:
+ def __init__(self, raw, *, url, content_type, status=200):
+ self.raw = raw
+ self.url = url
+ self.headers = {"Content-Type": content_type}
+ self.status = status
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def geturl(self):
+ return self.url
+
+ def read(self, limit=-1):
+ return self.raw if limit < 0 else self.raw[:limit]
+
+
+class CommunityRosterTests(unittest.TestCase):
+ def fixture(self):
+ return (
+ {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair Person"}}, "roster": {"chair": {}, "zeta": {}}}}},
+ {"projects": {"hugegraph": {"owners": ["zeta", "chair"], "members": ["other", "zeta", "chair"]}}},
+ {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": "Alpha Owner"}, "other": {"name": "Beta Committer"}}},
+ {"schema_version": 1, "mappings": {}},
+ )
+
+ def test_build_roster_derives_roles_and_order(self):
+ candidate = roster.build_roster(*self.fixture())
+ self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in candidate["roles"]["pmc"]])
+ self.assertEqual(["other"], [p["asf_id"] for p in candidate["roles"]["committers"]])
+ self.assertTrue(candidate["roles"]["pmc"][0]["chair"])
+
+ def test_same_names_use_asf_id_tiebreaker_across_hash_seeds(self):
+ program = f"""
+import importlib.util, json
+spec = importlib.util.spec_from_file_location("community_roster", {str(ROOT / "scripts/community_roster.py")!r})
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+committee = {{"committees": {{"hugegraph": {{"chair": {{"chair": {{}}}}, "roster": {{"chair": {{}}, "zeta": {{}}, "alpha": {{}}}}}}}}}}
+projects = {{"projects": {{"hugegraph": {{"owners": ["zeta", "chair", "alpha"], "members": ["zeta", "chair", "alpha"]}}}}}}
+people = {{"people": {{"chair": {{"name": "Chair"}}, "zeta": {{"name": "Same Name"}}, "alpha": {{"name": "Same Name"}}}}}}
+result = module.build_roster(committee, projects, people, {{"schema_version": 1, "mappings": {{}}}})
+print(json.dumps([person["asf_id"] for person in result["roles"]["pmc"]]))
+"""
+ outputs = []
+ for seed in ("1", "777"):
+ environment = {**os.environ, "PYTHONHASHSEED": seed}
+ outputs.append(subprocess.check_output([sys.executable, "-c", program], env=environment, text=True))
+ self.assertEqual(outputs[0], outputs[1])
+ self.assertEqual(["chair", "alpha", "zeta"], json.loads(outputs[0]))
+
+ def test_build_roster_rejects_committee_ldap_drift(self):
+ committee, projects, people, mapping = self.fixture()
+ committee["committees"]["hugegraph"]["roster"].pop("zeta")
+ with self.assertRaisesRegex(roster.RosterError, "disagree"):
+ roster.build_roster(committee, projects, people, mapping)
+
+ def test_build_roster_rejects_duplicate_ldap_ids(self):
+ for field in ("owners", "members"):
+ with self.subTest(field=field):
+ committee, projects, people, mapping = self.fixture()
+ projects["projects"]["hugegraph"][field].append(
+ projects["projects"]["hugegraph"][field][0]
+ )
+ with self.assertRaisesRegex(
+ roster.RosterError,
+ rf"LDAP project {field} contains duplicate ASF IDs",
+ ):
+ roster.build_roster(committee, projects, people, mapping)
+
+ def test_mapping_requires_unique_numeric_ids(self):
+ mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", "user_id": 1}, "two": {"login": "other", "user_id": 1}}}
+ with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub user_id"):
+ roster._validate_mapping(mapping, {"one", "two"})
+
+ def test_mapping_rejects_invalid_identity_characters(self):
+ with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"):
+ roster._validate_mapping(
+ {"schema_version": 1, "mappings": {"Bad ID": {"login": "valid", "user_id": 1}}},
+ {"Bad ID"},
+ )
+ with self.assertRaisesRegex(roster.RosterError, "invalid GitHub login"):
+ roster._validate_mapping(
+ {"schema_version": 1, "mappings": {"valid": {"login": "bad/login", "user_id": 1}}},
+ {"valid"},
+ )
+
+ def test_avatar_metadata_is_stripped(self):
+ vp8x = b"VP8X" + (10).to_bytes(4, "little") + bytes([0x2D]) + b"\0" * 9
+ exif = b"EXIF" + (4).to_bytes(4, "little") + b"meta"
+ iccp = b"ICCP" + (4).to_bytes(4, "little") + b"icc!"
+ payload = b"WEBP" + vp8x + exif + iccp
+ raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload
+ stripped = roster._strip_webp_metadata(raw)
+ self.assertNotIn(b"EXIF", stripped)
+ self.assertNotIn(b"ICCP", stripped)
+ self.assertEqual(0, stripped[20] & 0x2D)
+
+ def test_truncated_vp8x_without_image_bitstream_is_rejected(self):
+ vp8x = b"VP8X" + (10).to_bytes(4, "little") + b"\0" * 10
+ payload = b"WEBP" + vp8x
+ raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload
+ with self.assertRaisesRegex(roster.RosterError, "no decodable"):
+ roster._validate_webp(raw)
+
+ def test_network_response_contracts_are_bounded_and_allowlisted(self):
+ with self.assertRaisesRegex(roster.RosterError, "not allowlisted"):
+ roster._read_bounded_response(
+ FakeResponse(b"{}", url="https://evil.example/data", content_type="application/json"),
+ expected_hosts={"whimsy.apache.org"},
+ content_types={"application/json"},
+ limit=10,
+ kind="JSON source",
+ )
+ with self.assertRaisesRegex(roster.RosterError, "Content-Type"):
+ roster._read_bounded_response(
+ FakeResponse(b"{}", url="https://whimsy.apache.org/data", content_type="text/html"),
+ expected_hosts={"whimsy.apache.org"},
+ content_types={"application/json"},
+ limit=10,
+ kind="JSON source",
+ )
+ with self.assertRaisesRegex(roster.RosterError, "exceeds"):
+ roster._read_bounded_response(
+ FakeResponse(b"x" * 11, url="https://whimsy.apache.org/data", content_type="application/json"),
+ expected_hosts={"whimsy.apache.org"},
+ content_types={"application/json"},
+ limit=10,
+ kind="JSON source",
+ )
+
+ def test_redirect_is_rejected_before_following_disallowed_host(self):
+ handler = roster._AllowlistedRedirectHandler({"whimsy.apache.org"}, "JSON source")
+ with self.assertRaisesRegex(roster.RosterError, "not allowlisted"):
+ handler.redirect_request(
+ mock.Mock(),
+ None,
+ 302,
+ "Found",
+ {},
+ "http://127.0.0.1/private",
+ )
+
+ def test_malformed_json_and_encoder_timeout_are_roster_errors(self):
+ response = FakeResponse(
+ b"{bad",
+ url="https://whimsy.apache.org/public/committee-info.json",
+ content_type="application/json",
+ )
+ with mock.patch.object(roster, "_open_allowlisted", return_value=response):
+ with self.assertRaisesRegex(roster.RosterError, "malformed JSON"):
+ roster._fetch_json(roster.SOURCES["committee"])
+ avatar = FakeResponse(
+ b"not-an-image",
+ url="https://avatars.githubusercontent.com/u/1?s=128&v=4",
+ content_type="image/png",
+ )
+ with mock.patch.object(roster, "_open_allowlisted", return_value=avatar), \
+ mock.patch.object(roster.shutil, "which", return_value="/fake/cwebp"), \
+ mock.patch.object(roster.subprocess, "run", side_effect=subprocess.TimeoutExpired("cwebp", 20)):
+ with self.assertRaisesRegex(roster.RosterError, "cwebp failed"):
+ roster._avatar_bytes(1)
+
+ def test_nested_source_schema_errors_are_roster_errors(self):
+ committee, projects, people, mapping = self.fixture()
+ projects["projects"] = []
+ with self.assertRaisesRegex(roster.RosterError, "projects and committees objects"):
+ roster.build_roster(committee, projects, people, mapping)
+ committee, projects, people, mapping = self.fixture()
+ projects["projects"]["hugegraph"]["owners"] = [[]]
+ with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"):
+ roster.build_roster(committee, projects, people, mapping)
+ with tempfile.TemporaryDirectory(prefix="community-json-root-") as directory:
+ path = pathlib.Path(directory) / "array.json"
+ path.write_text("[]")
+ with self.assertRaisesRegex(roster.RosterError, "JSON root must be an object"):
+ roster._read_json(path)
+
+ def test_checked_in_bundle_validates(self):
+ self.assertEqual([], roster.validate_bundle(90))
+
+ def test_unmapped_profile_must_be_exact_phonebook_url(self):
+ with tempfile.TemporaryDirectory(prefix="community-profile-test-") as directory:
+ root = pathlib.Path(directory)
+ candidate = json.loads(roster.ROSTER_PATH.read_text())
+ candidate["roles"]["committers"][0]["profile_url"] = "https://example.invalid/profile"
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(roster.MAP_PATH.read_text())
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, "unmapped profile URL mismatch"):
+ roster.validate_bundle(90)
+
+ def test_chair_values_must_be_strict_booleans(self):
+ with tempfile.TemporaryDirectory(prefix="community-chair-test-") as directory:
+ root = pathlib.Path(directory)
+ candidate = json.loads(roster.ROSTER_PATH.read_text())
+ candidate["roles"]["committers"][0]["chair"] = 0
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(roster.MAP_PATH.read_text())
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, "chair must be boolean"):
+ roster.validate_bundle(90)
+
+ def test_avatar_path_rejects_extra_segments_and_symlinks(self):
+ base = json.loads(roster.ROSTER_PATH.read_text())
+ asf_id = base["roles"]["committers"][0]["asf_id"]
+ mapping = {"schema_version": 1, "mappings": {asf_id: {"login": "valid-user", "user_id": 1}}}
+ for avatar in (
+ "/img/community/avatars/extra/" + "a" * 64 + ".webp",
+ "/img/community/avatars/../" + "a" * 64 + ".webp",
+ ):
+ with self.subTest(avatar=avatar), tempfile.TemporaryDirectory(prefix="community-avatar-path-") as directory:
+ root = pathlib.Path(directory)
+ candidate = json.loads(json.dumps(base))
+ member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id)
+ member.update(github=mapping["mappings"][asf_id], avatar=avatar, profile_url="https://github.com/valid-user")
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(json.dumps(mapping))
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, "needs a local avatar"):
+ roster.validate_bundle(90)
+ with tempfile.TemporaryDirectory(prefix="community-avatar-link-") as directory:
+ root = pathlib.Path(directory)
+ avatar_dir = root / "avatars"
+ avatar_dir.mkdir()
+ raw = b"target"
+ digest = hashlib.sha256(raw).hexdigest()
+ target = root / "target.webp"
+ target.write_bytes(raw)
+ (avatar_dir / f"{digest}.webp").symlink_to(target)
+ candidate = json.loads(json.dumps(base))
+ member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id)
+ member.update(
+ github=mapping["mappings"][asf_id],
+ avatar=f"/img/community/avatars/{digest}.webp",
+ profile_url="https://github.com/valid-user",
+ )
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(json.dumps(mapping))
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "AVATAR_DIR", avatar_dir):
+ with self.assertRaisesRegex(roster.RosterError, "must not be a symlink"):
+ roster.validate_bundle(90)
+
+ def test_avatar_directory_parent_symlink_is_rejected(self):
+ with tempfile.TemporaryDirectory(prefix="community-avatar-parent-") as directory:
+ root = pathlib.Path(directory)
+ outside = root / "outside"
+ outside.mkdir()
+ avatar_link = root / "static" / "img" / "community" / "avatars"
+ avatar_link.parent.mkdir(parents=True)
+ avatar_link.symlink_to(outside, target_is_directory=True)
+ with mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root / "data" / "community"), \
+ mock.patch.object(roster, "ROSTER_PATH", root / "data" / "community" / "roster.json"), \
+ mock.patch.object(roster, "MAP_PATH", root / "data" / "community" / "github-map.json"), \
+ mock.patch.object(roster, "AVATAR_DIR", avatar_link):
+ with self.assertRaisesRegex(roster.RosterError, "symlink path components"):
+ roster._validate_repo_paths()
+
+ def test_member_name_and_initials_must_be_non_empty_and_derived(self):
+ base = json.loads(roster.ROSTER_PATH.read_text())
+ mapping = json.loads(roster.MAP_PATH.read_text())
+ for field, value, message in (
+ ("name", "", "name must be non-empty"),
+ ("initials", "", "initials mismatch"),
+ ):
+ with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-identity-") as directory:
+ root = pathlib.Path(directory)
+ candidate = json.loads(json.dumps(base))
+ candidate["roles"]["committers"][0][field] = value
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(json.dumps(mapping))
+ with mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, message):
+ roster.validate_bundle(90)
+
+ def test_local_roster_schema_errors_are_roster_errors(self):
+ base = json.loads(roster.ROSTER_PATH.read_text())
+ mapping = json.loads(roster.MAP_PATH.read_text())
+ mutations = (
+ ("asf_id", [], "invalid ASF ID"),
+ ("name", 123, "name must be non-empty"),
+ ("retrieved_at", None, "ISO-8601 UTC string"),
+ )
+ for field, value, message in mutations:
+ with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-schema-") as directory:
+ root = pathlib.Path(directory)
+ candidate = json.loads(json.dumps(base))
+ if field == "retrieved_at":
+ candidate[field] = value
+ else:
+ candidate["roles"]["committers"][0][field] = value
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(json.dumps(mapping))
+ with mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, message):
+ roster.validate_bundle(90)
+
+ def test_refresh_validates_paths_before_creating_data_directory(self):
+ with tempfile.TemporaryDirectory(prefix="community-refresh-path-") as directory:
+ root = pathlib.Path(directory)
+ outside = root / "outside"
+ outside.mkdir()
+ (root / "data").symlink_to(outside, target_is_directory=True)
+ data_dir = root / "data" / "community"
+ with mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", data_dir), \
+ mock.patch.object(roster, "ROSTER_PATH", data_dir / "roster.json"), \
+ mock.patch.object(roster, "MAP_PATH", data_dir / "github-map.json"), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "static" / "img" / "community" / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, "symlink path components"):
+ roster.refresh()
+ self.assertFalse((outside / "community").exists())
+
+ def test_validator_rejects_same_name_out_of_asf_id_order(self):
+ committee, projects, people, mapping = self.fixture()
+ committee["committees"]["hugegraph"]["roster"]["alpha"] = {}
+ projects["projects"]["hugegraph"]["owners"].append("alpha")
+ projects["projects"]["hugegraph"]["members"].append("alpha")
+ people["people"]["zeta"]["name"] = "Same Name"
+ people["people"]["alpha"] = {"name": "Same Name"}
+ candidate = roster.build_roster(committee, projects, people, mapping)
+ candidate["roles"]["pmc"][1:] = reversed(candidate["roles"]["pmc"][1:])
+ with tempfile.TemporaryDirectory(prefix="community-order-test-") as directory:
+ root = pathlib.Path(directory)
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_text(json.dumps(candidate))
+ map_path.write_text(json.dumps(mapping))
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+ with self.assertRaisesRegex(roster.RosterError, "sorted by public name"):
+ roster.validate_bundle(90)
+
+ def test_fetch_failure_preserves_last_good(self):
+ original, old_fetch = roster.ROSTER_PATH.read_bytes(), roster._fetch_json
+ try:
+ roster._fetch_json = lambda _url: (_ for _ in ()).throw(OSError("network down"))
+ with self.assertRaises(OSError):
+ roster.refresh()
+ finally:
+ roster._fetch_json = old_fetch
+ self.assertEqual(original, roster.ROSTER_PATH.read_bytes())
+
+ def test_refresh_duplicate_ldap_ids_preserves_last_good(self):
+ for field in ("owners", "members"):
+ with self.subTest(field=field), tempfile.TemporaryDirectory(
+ prefix="community-duplicate-test-"
+ ) as directory:
+ root = pathlib.Path(directory)
+ data_dir = root / "data"
+ roster_path, map_path = data_dir / "roster.json", data_dir / "github-map.json"
+ data_dir.mkdir()
+ roster_path.write_bytes(b"last-good\n")
+ committee, projects, people, mapping = self.fixture()
+ projects["projects"]["hugegraph"][field].append(
+ projects["projects"]["hugegraph"][field][0]
+ )
+ sources = {
+ roster.SOURCES["committee"]: committee,
+ roster.SOURCES["projects"]: projects,
+ roster.SOURCES["people"]: people,
+ }
+ map_path.write_text(json.dumps(mapping))
+ with mock.patch.object(roster, "ROOT", root), \
+ mock.patch.object(roster, "DATA_DIR", data_dir), \
+ mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "AVATAR_DIR", root / "avatars"), \
+ mock.patch.object(roster, "_fetch_json", side_effect=sources.__getitem__), \
+ mock.patch.object(roster, "_commit_bundle") as commit:
+ with self.assertRaisesRegex(
+ roster.RosterError,
+ rf"LDAP project {field} contains duplicate ASF IDs",
+ ):
+ roster.refresh()
+ commit.assert_not_called()
+ self.assertEqual(b"last-good\n", roster_path.read_bytes())
+
+ def test_copy_failure_preserves_last_good_bundle(self):
+ with tempfile.TemporaryDirectory(prefix="community-copy-test-") as directory:
+ root = pathlib.Path(directory)
+ roster_path, avatar_dir = root / "roster.json", root / "avatars"
+ avatar_dir.mkdir()
+ roster_path.write_bytes(b"last-good\n")
+ (avatar_dir / "old.webp").write_bytes(b"old")
+ candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}}
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+ mock.patch.object(roster, "_validate_repo_paths"), \
+ mock.patch.object(roster, "_validate_avatar_blob"), \
+ mock.patch.object(roster, "_copy_candidate", side_effect=OSError("copy failed")):
+ with self.assertRaisesRegex(OSError, "copy failed"):
+ roster._commit_bundle(candidate, {"new.webp": b"new"})
+ self.assertEqual(b"last-good\n", roster_path.read_bytes())
+ self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes())
+
+ def test_candidate_cleanup_failure_does_not_publish_roster(self):
+ with tempfile.TemporaryDirectory(prefix="community-cleanup-test-") as directory:
+ root = pathlib.Path(directory)
+ roster_path, map_path = root / "roster.json", root / "github-map.json"
+ roster_path.write_bytes(b"last-good\n")
+ map_path.write_text('{"schema_version": 1, "mappings": {}}')
+ candidate = {"roles": {"pmc": [], "committers": []}}
+ with mock.patch.object(roster, "DATA_DIR", root), \
+ mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "MAP_PATH", map_path), \
+ mock.patch.object(roster, "_validate_repo_paths"), \
+ mock.patch.object(roster, "_fetch_json", return_value={}), \
+ mock.patch.object(roster, "build_roster", return_value=candidate), \
+ mock.patch.object(roster, "_install_avatars"), \
+ mock.patch.object(roster.shutil, "rmtree", side_effect=OSError("cleanup failed")):
+ with self.assertRaisesRegex(OSError, "cleanup failed"):
+ roster.refresh()
+ self.assertEqual(b"last-good\n", roster_path.read_bytes())
+
+ def test_atomic_roster_write_failure_keeps_last_good_selected(self):
+ with tempfile.TemporaryDirectory(prefix="community-write-test-") as directory:
+ root = pathlib.Path(directory)
+ roster_path, avatar_dir = root / "roster.json", root / "avatars"
+ avatar_dir.mkdir()
+ roster_path.write_bytes(b"last-good\n")
+ candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}}
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+ mock.patch.object(roster, "_validate_repo_paths"), \
+ mock.patch.object(roster, "_validate_avatar_blob"), \
+ mock.patch.object(roster, "_atomic_write", side_effect=OSError("write failed")):
+ with self.assertRaisesRegex(OSError, "write failed"):
+ roster._commit_bundle(candidate, {"new.webp": b"new"})
+ self.assertEqual(b"last-good\n", roster_path.read_bytes())
+
+ def test_orphan_unlink_failure_is_a_successful_commit_warning(self):
+ with tempfile.TemporaryDirectory(prefix="community-unlink-test-") as directory:
+ root = pathlib.Path(directory)
+ roster_path, avatar_dir = root / "roster.json", root / "avatars"
+ avatar_dir.mkdir()
+ roster_path.write_bytes(b"last-good\n")
+ (avatar_dir / "old.webp").write_bytes(b"old")
+ candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}}
+ real_unlink, failed = roster._unlink, False
+
+ def fail_once(path):
+ nonlocal failed
+ if path.name == "old.webp" and not failed:
+ failed = True
+ raise OSError("unlink failed")
+ real_unlink(path)
+
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+ mock.patch.object(roster, "_validate_repo_paths"), \
+ mock.patch.object(roster, "_validate_avatar_blob"), \
+ mock.patch.object(roster, "_unlink", side_effect=fail_once):
+ roster._commit_bundle(candidate, {"new.webp": b"new"})
+ self.assertNotEqual(b"last-good\n", roster_path.read_bytes())
+ self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes())
+ self.assertEqual(b"new", (avatar_dir / "new.webp").read_bytes())
+
+ def test_corrupt_existing_candidate_destination_is_replaced(self):
+ with tempfile.TemporaryDirectory(prefix="community-replace-test-") as directory:
+ root = pathlib.Path(directory)
+ roster_path, avatar_dir = root / "roster.json", root / "avatars"
+ avatar_dir.mkdir()
+ roster_path.write_bytes(b"last-good\n")
+ raw = b"new"
+ name = f"{__import__('hashlib').sha256(raw).hexdigest()}.webp"
+ destination = avatar_dir / name
+ destination.write_bytes(b"corrupt")
+ candidate = {"roles": {"pmc": [{"avatar": f"/img/community/avatars/{name}"}], "committers": []}}
+ with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+ mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+ mock.patch.object(roster, "_validate_repo_paths"), \
+ mock.patch.object(roster, "_validate_webp"):
+ roster._commit_bundle(candidate, {name: raw})
+ self.assertEqual(raw, destination.read_bytes())
+
+
+class CommunityContentContractTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls._site = tempfile.TemporaryDirectory(prefix="community-content-site-")
+ hugo_version = subprocess.check_output(["hugo", "version"], text=True)
+ if not hugo_version.startswith("hugo v0.165.0") or "+extended" not in hugo_version:
+ raise RuntimeError(
+ f"Community render contracts require Hugo v0.165.0 Extended: {hugo_version.strip()}"
+ )
+ environment = {**os.environ, "GOPROXY": "off"}
+ subprocess.run(
+ ["hugo", "--quiet", "--destination", cls._site.name],
+ cwd=ROOT,
+ env=environment,
+ text=True,
+ capture_output=True,
+ check=True,
+ )
+ cls.site = pathlib.Path(cls._site.name)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls._site.cleanup()
+
+ def test_search_metadata_covers_fixed_bilingual_entries(self):
+ entries = [
+ "docs/introduction/_index.md",
+ "docs/quickstart/hugegraph/hugegraph-server.md",
+ "docs/quickstart/hugegraph/hugegraph-hstore.md",
+ "docs/quickstart/hugegraph/hugegraph-pd.md",
+ "docs/quickstart/computing/hugegraph-computer.md",
+ "docs/quickstart/toolchain/hugegraph-loader.md",
+ "docs/quickstart/toolchain/hugegraph-hubble.md",
+ "docs/clients/_index.md",
+ "docs/clients/restful-api/_index.md",
+ "docs/config/config-guide.md",
+ "docs/config/config-authentication.md",
+ "docs/download/download.md",
+ ]
+ for language in ("en", "cn"):
+ for relative in entries:
+ text = (ROOT / "content" / language / relative).read_text(encoding="utf-8")
+ self.assertIn("search_keywords:", text, f"{language}/{relative}")
+ self.assertIn("search_boost:", text, f"{language}/{relative}")
+
+ def test_docs_roots_respect_core_platform_llmsfull_ownership(self):
+ versions = {
+ entry["id"]
+ for entry in json.loads((ROOT / "versions.json").read_text(encoding="utf-8"))["versions"]
+ }
+ core_platform_integrated = {"1.3", "1.0"} <= versions
+ for language in ("en", "cn"):
+ text = (ROOT / "content" / language / "docs/_index.md").read_text(encoding="utf-8")
+ frontmatter = text.split("---", 2)[1]
+ if core_platform_integrated:
+ self.assertIn("outputs: [HTML, RSS, print, markdown, LLMSFULL]", frontmatter)
+ else:
+ self.assertNotIn("LLMSFULL", frontmatter)
+
+ def test_component_pilots_are_bilingual_and_scoped(self):
+ for language in ("en", "cn"):
+ server = (ROOT / "content" / language / "docs/quickstart/hugegraph/hugegraph-server.md").read_text()
+ config = (ROOT / "content" / language / "docs/config/config-guide.md").read_text()
+ vertex = (ROOT / "content" / language / "docs/clients/restful-api/vertex.md").read_text()
+ self.assertIn("{.steps}", server)
+ self.assertIn('filename="conf/gremlin-server.yaml"', config)
+ self.assertIn(".full-width", vertex)
+ self.assertIn("{#vertex-id-strategy", vertex)
+
+ def test_component_pilots_render_in_html_print_and_markdown(self):
+ for prefix in ("", "cn/"):
+ outputs = {
+ "server_html": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.html",
+ "server_print": self.site / prefix / "_print/docs/quickstart/hugegraph/index.html",
+ "server_md": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.md",
+ "config_html": self.site / prefix / "docs/config/config-guide/index.html",
+ "config_print": self.site / prefix / "_print/docs/config/index.html",
+ "config_md": self.site / prefix / "docs/config/config-guide/index.md",
+ "vertex_html": self.site / prefix / "docs/clients/restful-api/vertex/index.html",
+ "vertex_print": self.site / prefix / "_print/docs/clients/restful-api/index.html",
+ "vertex_md": self.site / prefix / "docs/clients/restful-api/vertex/index.md",
+ }
+ rendered = {key: path.read_text(encoding="utf-8") for key, path in outputs.items()}
+ self.assertIn('class="steps"', rendered["server_html"])
+ self.assertIn('class="steps"', rendered["server_print"])
+ self.assertIn("{.steps}", rendered["server_md"])
+ for key in ("config_html", "config_print", "config_md"):
+ self.assertIn("conf/gremlin-server.yaml", rendered[key])
+ self.assertIn('id="vertex-id-strategy"', rendered["vertex_html"])
+ self.assertIn('id="vertex-id-strategy"', rendered["vertex_print"])
+ self.assertIn("{#vertex-id-strategy .full-width", rendered["vertex_md"])
+
+ def test_community_markdown_follows_section_order_and_about_is_unchanged(self):
+ expected = {
+ "community/index.md": (
+ "## Join the Apache HugeGraph community",
+ "## Get involved",
+ "## Project members",
+ "## Learn how the project works",
+ ),
+ "cn/community/index.md": (
+ "## 加入 Apache HugeGraph 社区",
+ "## 参与社区",
+ "## 项目成员",
+ "## 了解项目运作方式",
+ ),
+ }
+ for relative, markers in expected.items():
+ rendered = (self.site / relative).read_text(encoding="utf-8")
+ expected_title = "# 社区" if relative.startswith("cn/") else "# Community"
+ self.assertTrue(rendered.startswith(expected_title + "\n"))
+ self.assertNotIn("td-page-meta__footer", rendered)
+ positions = [rendered.index(marker) for marker in markers]
+ self.assertEqual(positions, sorted(positions))
+ member_heading = "项目成员" if relative.startswith("cn/") else "Project members"
+ self.assertRegex(rendered, rf"(?m)^## {member_heading}$")
+ about = {
+ "about/index.md": (
+ "## One ecosystem for graph data and graph intelligence",
+ "HugeGraph is an Apache top-level project",
+ ),
+ "cn/about/index.md": (
+ "## 连接图数据与图智能的一体化生态",
+ "HugeGraph 是 Apache 顶级项目",
+ ),
+ }
+ for relative, markers in about.items():
+ rendered = (self.site / relative).read_text(encoding="utf-8")
+ self.assertNotIn("Project members", rendered)
+ for marker in markers:
+ self.assertIn(marker, rendered)
+
+ def test_explicit_artifact_validator_accepts_prebuilt_site(self):
+ roster.validate_rendered_outputs(self.site)
+ result = subprocess.run(
+ [
+ sys.executable,
+ "scripts/community_roster.py",
+ "validate",
+ "--warn-after-days",
+ "90",
+ "--artifact",
+ str(self.site),
+ ],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ )
+ self.assertEqual(0, result.returncode, result.stderr)
+
+ def test_artifact_validator_rejects_swapped_role_sections(self):
+ outputs = (
+ "community/index.html",
+ "_print/community/index.html",
+ "community/index.md",
+ "cn/community/index.html",
+ "cn/_print/community/index.html",
+ "cn/community/index.md",
+ )
+ for swapped_relative in (
+ "community/index.html",
+ "_print/community/index.html",
+ "cn/community/index.html",
+ "cn/_print/community/index.html",
+ ):
+ with self.subTest(output=swapped_relative), tempfile.TemporaryDirectory(
+ prefix="community-role-output-"
+ ) as directory:
+ destination = pathlib.Path(directory)
+ for relative in outputs:
+ target = destination / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(self.site / relative, target)
+ path = destination / swapped_relative
+ rendered = path.read_text(encoding="utf-8")
+ match = re.search(
+ r'(<section[^>]*data-community-role=(?:"pmc"|pmc)[^>]*>.*?</section>)'
+ r"(\s*)"
+ r'(<section[^>]*data-community-role=(?:"committers"|committers)[^>]*>.*?</section>)',
+ rendered,
+ flags=re.DOTALL,
+ )
+ self.assertIsNotNone(match)
+ rendered = (
+ rendered[: match.start()]
+ + match.group(3)
+ + match.group(2)
+ + match.group(1)
+ + rendered[match.end() :]
+ )
+ path.write_text(rendered, encoding="utf-8")
+ with self.assertRaisesRegex(roster.RosterError, "role section order drift"):
+ roster.validate_rendered_outputs(destination)
+
+ def test_artifact_validator_rejects_plain_text_profile_urls(self):
+ with tempfile.TemporaryDirectory(prefix="community-fake-output-") as directory:
+ destination = pathlib.Path(directory)
+ roles = json.loads(roster.ROSTER_PATH.read_text())["roles"]
+ for relative in (
+ "community/index.html",
+ "_print/community/index.html",
+ "cn/community/index.html",
+ "cn/_print/community/index.html",
+ ):
+ path = destination / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ '<section data-community-role="pmc">'
+ + " ".join(person["profile_url"] for person in roles["pmc"])
+ + '</section><section data-community-role="committers">'
+ + " ".join(person["profile_url"] for person in roles["committers"])
+ + "</section>"
+ )
+ for relative, title in (
+ ("community/index.md", "Project members"),
+ ("cn/community/index.md", "项目成员"),
+ ):
+ path = destination / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ f"## {title}\n\n### PMC\n"
+ + "\n".join(person["profile_url"] for person in roles["pmc"])
+ + "\n\n### Committers\n"
+ + "\n".join(person["profile_url"] for person in roles["committers"])
+ )
+ with self.assertRaisesRegex(roster.RosterError, "link parity drift"):
+ roster.validate_rendered_outputs(destination)
+
+ def test_fixed_metadata_is_present_in_actual_offline_indexes(self):
+ fixture = json.loads(
+ (ROOT / "scripts/fixtures/community_search_queries.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(24, len(fixture))
+ self.assertEqual(24, len({(item["locale"], item["query"]) for item in fixture}))
+ for language in ("en", "cn"):
+ indexes = list(self.site.glob(f"offline-search-index.{language}.*.json"))
+ self.assertEqual(1, len(indexes))
+ records = {record["ref"]: record for record in json.loads(indexes[0].read_text())}
+ for item in (entry for entry in fixture if entry["locale"] == language):
+ ref = item["expected_ref"]
+ self.assertIn(ref, records)
+ self.assertTrue(records[ref]["keywords"], ref)
+ self.assertGreater(records[ref]["boost"], 1, ref)
+ normalized_query = item["query"].casefold()
+ searchable = " ".join(
+ [records[ref]["title"], *records[ref]["keywords"]]
+ ).casefold()
+ self.assertIn(normalized_query, searchable, ref)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_download_data.py b/scripts/test_download_data.py
new file mode 100644
index 000000000..b763c7d5d
--- /dev/null
+++ b/scripts/test_download_data.py
@@ -0,0 +1,198 @@
+import json
+import pathlib
+import re
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+DATA = ROOT / "data" / "downloads" / "asf.json"
+PARTIAL = ROOT / "layouts" / "_partials" / "asf-downloads.html"
+PAGES = (
+ ROOT / "content" / "en" / "docs" / "download" / "download.md",
+ ROOT / "content" / "cn" / "docs" / "download" / "download.md",
+)
+I18N = (ROOT / "i18n" / "en.yaml", ROOT / "i18n" / "zh-CN.yaml")
+
+# Exact dist state, verified against
+# https://downloads.apache.org/hugegraph/<version>/ on 2026-09-13. The data
+# file must derive exactly these artifacts; a new release edits both the data
+# file and this expectation with a fresh dist listing.
+EXPECTED_ARTIFACTS = {
+ "1.7.0": {
+ "apache-hugegraph-incubating-1.7.0.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.7.0.tar.gz",
+ "apache-hugegraph-incubating-1.7.0-src.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz",
+ "apache-hugegraph-ai-incubating-1.7.0-src.tar.gz",
+ "apache-hugegraph-computer-incubating-1.7.0-src.tar.gz",
+ },
+ "1.5.0": {
+ "apache-hugegraph-incubating-1.5.0.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.5.0.tar.gz",
+ "apache-hugegraph-incubating-1.5.0-src.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz",
+ "apache-hugegraph-ai-incubating-1.5.0-src.tar.gz",
+ "apache-hugegraph-computer-incubating-1.5.0-src.tar.gz",
+ },
+ "1.3.0": {
+ "apache-hugegraph-incubating-1.3.0.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.3.0.tar.gz",
+ "apache-hugegraph-incubating-1.3.0-src.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz",
+ "apache-hugegraph-ai-incubating-1.3.0-src.tar.gz",
+ "apache-hugegraph-commons-incubating-1.3.0-src.tar.gz",
+ },
+ "1.2.0": {
+ "apache-hugegraph-incubating-1.2.0.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.2.0.tar.gz",
+ "apache-hugegraph-incubating-1.2.0-src.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz",
+ "apache-hugegraph-computer-incubating-1.2.0-src.tar.gz",
+ "apache-hugegraph-commons-incubating-1.2.0-src.tar.gz",
+ },
+ "1.0.0": {
+ "apache-hugegraph-incubating-1.0.0.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.0.0.tar.gz",
+ "apache-hugegraph-computer-incubating-1.0.0.tar.gz",
+ "apache-hugegraph-incubating-1.0.0-src.tar.gz",
+ "apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz",
+ "apache-hugegraph-computer-incubating-1.0.0-src.tar.gz",
+ "apache-hugegraph-commons-incubating-1.0.0-src.tar.gz",
+ },
+}
+
+FIXED_I18N_KEYS = (
+ "ui_assets_download",
+ "download_release_version",
+ "download_release_date",
+ "download_release_notes",
+ "download_table_component",
+ "download_table_type",
+ "download_table_mirror",
+ "download_type_binary",
+ "download_type_source",
+ "download_asf_note",
+)
+
+
+def load_data() -> dict:
+ with DATA.open(encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def derive_files(release: dict, components: dict) -> set[str]:
+ files = set()
+ infix = "-incubating" if release["incubating"] else ""
+ for kind, suffix in (("binary", ""), ("source", "-src")):
+ for component_id in release.get(kind, []):
+ prefix = components[component_id]["prefix"]
+ files.add(f"{prefix}{infix}-{release['version']}{suffix}.tar.gz")
+ return files
+
+
+def i18n_keys(path: pathlib.Path) -> set[str]:
+ keys = set()
+ for line in path.read_text(encoding="utf-8").splitlines():
+ if line and not line.startswith(("#", " ")) and ":" in line:
+ keys.add(line.split(":", 1)[0].strip())
+ return keys
+
+
+class DownloadDataTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.data = load_data()
+
+ def test_schema_and_release_ordering(self) -> None:
+ data = self.data
+ self.assertEqual(
+ set(data), {"$comment", "dist_path", "components", "releases"}
+ )
+ self.assertEqual(data["dist_path"], "hugegraph")
+ label_keys = [c["label_key"] for c in data["components"].values()]
+ self.assertEqual(len(label_keys), len(set(label_keys)))
+ for component_id, component in data["components"].items():
+ self.assertRegex(component_id, r"^[a-z][a-z0-9]*\Z")
+ self.assertRegex(component["prefix"], r"^apache-hugegraph(-[a-z]+)?\Z")
+ self.assertRegex(component["label_key"], r"^download_component_[a-z]+\Z")
+ releases = data["releases"]
+ versions = [release["version"] for release in releases]
+ self.assertEqual(versions, sorted(versions, key=lambda v: tuple(map(int, v.split("."))), reverse=True))
+ self.assertEqual(len(versions), len(set(versions)))
+ self.assertEqual(sum(1 for release in releases if release.get("latest")), 1)
+ self.assertTrue(releases[0].get("latest"), "the newest release must be the latest")
+ for release in releases:
+ self.assertRegex(release["version"], r"^\d+\.\d+\.\d+\Z")
+ self.assertRegex(release["date"], r"^\d{4}-\d{2}-\d{2}\Z")
+ self.assertIsInstance(release["incubating"], bool)
+ for kind in ("binary", "source"):
+ ids = release.get(kind, [])
+ self.assertTrue(ids, f"{release['version']} has no {kind} artifacts")
+ self.assertEqual(len(ids), len(set(ids)))
+ for component_id in ids:
+ self.assertIn(component_id, data["components"])
+
+ def test_derived_artifacts_match_the_verified_dist_listing(self) -> None:
+ data = self.data
+ derived = {
+ release["version"]: derive_files(release, data["components"])
+ for release in data["releases"]
+ }
+ self.assertEqual(derived, EXPECTED_ARTIFACTS)
+ total = sum(len(files) for files in derived.values())
+ self.assertEqual(total, 31)
+
+ def test_derived_urls_are_well_formed(self) -> None:
+ data = self.data
+ pattern = re.compile(r"^https://[a-z.]+/[A-Za-z0-9./?=_-]+$")
+ for release in data["releases"]:
+ for file in derive_files(release, data["components"]):
+ urls = (
+ f"https://www.apache.org/dyn/closer.lua/{data['dist_path']}/"
+ f"{release['version']}/{file}?action=download",
+ f"https://downloads.apache.org/{data['dist_path']}/"
+ f"{release['version']}/{file}.asc",
+ f"https://downloads.apache.org/{data['dist_path']}/"
+ f"{release['version']}/{file}.sha512",
+ )
+ for url in urls:
+ self.assertRegex(url, pattern)
+
+ def test_pages_render_from_data_not_hardcoded_tables(self) -> None:
+ for page in PAGES:
+ text = page.read_text(encoding="utf-8")
+ self.assertIn("{{< asf-downloads latest >}}", text, page)
+ self.assertIn("{{< asf-downloads archived >}}", text, page)
+ self.assertNotIn("closer.lua", text, page)
+ self.assertNotIn(".tar.gz", text, page)
+ artifact_links = re.findall(
+ r"downloads\.apache\.org/hugegraph/\d", text
+ )
+ self.assertEqual(artifact_links, [], page)
+
+ def test_partial_derives_urls_with_the_same_tokens(self) -> None:
+ # The partial re-derives the same filenames and URLs in Go templates;
+ # lock its literal format tokens to this module's derivation so the
+ # two cannot drift apart silently.
+ template = PARTIAL.read_text(encoding="utf-8")
+ for token in (
+ '"-incubating" ""',
+ '"-src" ""',
+ '"%s%s-%s%s.tar.gz" $component.prefix $infix $version $suffix',
+ 'https://www.apache.org/dyn/closer.lua/%s/%s/%s?action=download',
+ 'https://downloads.apache.org/%s/%s/%s.asc',
+ 'https://downloads.apache.org/%s/%s/%s.sha512',
+ ):
+ self.assertIn(token, template)
+
+ def test_i18n_catalogues_carry_every_label(self) -> None:
+ data = self.data
+ required = set(FIXED_I18N_KEYS)
+ for component in data["components"].values():
+ required.add(component["label_key"])
+ for catalogue in I18N:
+ missing = required - i18n_keys(catalogue)
+ self.assertEqual(missing, set(), catalogue)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_hugo_wrapper.py b/scripts/test_hugo_wrapper.py
new file mode 100644
index 000000000..256930653
--- /dev/null
+++ b/scripts/test_hugo_wrapper.py
@@ -0,0 +1,204 @@
+import json
+import os
+import pathlib
+import stat
+import subprocess
+import tempfile
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+WRAPPER = ROOT / "scripts" / "hugo.sh"
+
+
+class HugoWrapperTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temp = tempfile.TemporaryDirectory()
+ self.root = pathlib.Path(self.temp.name)
+ self.bin = self.root / "bin"
+ self.bin.mkdir()
+ self.log = self.root / "calls.jsonl"
+ self._write_executable(
+ "python3",
+ """#!/bin/sh
+printf '{"tool":"python3","args":[' >> "$HG_WRAPPER_TEST_LOG"
+first=1
+output=
+previous=
+for arg in "$@"; do
+ if [ "$first" -eq 0 ]; then printf ',' >> "$HG_WRAPPER_TEST_LOG"; fi
+ first=0
+ printf '"%s"' "$arg" >> "$HG_WRAPPER_TEST_LOG"
+ if [ "$previous" = "--output" ]; then output=$arg; fi
+ previous=$arg
+done
+printf ']}\\n' >> "$HG_WRAPPER_TEST_LOG"
+printf '%s\\n' '{"params":{"versions":[1,2,3,4,5]}}' > "$output"
+""",
+ )
+ self._write_executable(
+ "hugo",
+ """#!/bin/sh
+printf '{"tool":"hugo","args":[' >> "$HG_WRAPPER_TEST_LOG"
+first=1
+config=
+previous=
+for arg in "$@"; do
+ if [ "$first" -eq 0 ]; then printf ',' >> "$HG_WRAPPER_TEST_LOG"; fi
+ first=0
+ printf '"%s"' "$arg" >> "$HG_WRAPPER_TEST_LOG"
+ if [ "$previous" = "--config" ]; then config=$arg; fi
+ previous=$arg
+done
+printf ']}\\n' >> "$HG_WRAPPER_TEST_LOG"
+generated=${config#*,}
+test -f "$generated"
+grep -q '"versions":\\[1,2,3,4,5\\]' "$generated"
+""",
+ )
+
+ def tearDown(self) -> None:
+ self.temp.cleanup()
+
+ def _write_executable(self, name: str, source: str) -> None:
+ path = self.bin / name
+ path.write_text(source, encoding="utf-8")
+ path.chmod(path.stat().st_mode | stat.S_IXUSR)
+
+ def invoke_wrapper(
+ self, *args: str, environment_overrides: dict[str, str] | None = None
+ ) -> subprocess.CompletedProcess[str]:
+ environment = os.environ.copy()
+ environment["PATH"] = f"{self.bin}{os.pathsep}{environment['PATH']}"
+ environment["HG_WRAPPER_TEST_LOG"] = str(self.log)
+ environment.pop("HG_DOC_VERSION", None)
+ environment.pop("HG_DOC_SITE_ORIGIN", None)
+ if environment_overrides:
+ environment.update(environment_overrides)
+ return subprocess.run(
+ [str(WRAPPER), *args],
+ cwd=ROOT,
+ env=environment,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ def calls(self) -> list[dict]:
+ if not self.log.exists():
+ return []
+ return [
+ json.loads(line)
+ for line in self.log.read_text(encoding="utf-8").splitlines()
+ ]
+
+ def run_wrapper(
+ self, *args: str, environment_overrides: dict[str, str] | None = None
+ ) -> list[dict]:
+ result = self.invoke_wrapper(
+ *args, environment_overrides=environment_overrides
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ return self.calls()
+
+ def test_server_derives_manifest_config_and_safely_forwards_arguments(self) -> None:
+ calls = self.run_wrapper("server", "--port", "1414", "--bind", "127.0.0.1")
+ self.assertEqual(calls[0]["tool"], "python3")
+ self.assertEqual(
+ calls[0]["args"][0:2], ["scripts/versioning.py", "config"]
+ )
+ self.assertNotIn("--version", calls[0]["args"])
+ self.assertIn("--site-origin", calls[0]["args"])
+ origin_index = calls[0]["args"].index("--site-origin") + 1
+ self.assertEqual(calls[0]["args"][origin_index], "http://localhost:1414/")
+ self.assertEqual(calls[1]["args"][0], "server")
+ self.assertEqual(
+ calls[1]["args"][-4:],
+ ["--port", "1414", "--bind", "127.0.0.1"],
+ )
+
+ def test_explicit_version_is_forwarded_only_when_requested(self) -> None:
+ calls = self.run_wrapper(
+ "build", environment_overrides={"HG_DOC_VERSION": "1.7"}
+ )
+ args = calls[0]["args"]
+ self.assertEqual(args[args.index("--version") + 1], "1.7")
+
+ def test_base_url_flag_wins_over_environment_and_pins_the_origin(self) -> None:
+ calls = self.run_wrapper(
+ "server",
+ "--baseURL=https://preview.example/docs/",
+ environment_overrides={
+ "HG_DOC_SITE_ORIGIN": "https://other.example/"
+ },
+ )
+ config_args = calls[0]["args"]
+ self.assertEqual(
+ config_args[config_args.index("--site-origin") + 1],
+ "https://preview.example/docs/",
+ )
+ hugo_args = calls[1]["args"]
+ self.assertIn("--appendPort=false", hugo_args)
+ self.assertEqual(hugo_args[-1], "--baseURL=https://preview.example/docs/")
+
+ def test_documented_origin_and_port_spellings_drive_generated_config(self) -> None:
+ cases = (
+ (("--baseURL", "https://long.example/"), "https://long.example/", True),
+ (("-b", "https://short.example/"), "https://short.example/", True),
+ (("-b=https://equals.example/",), "https://equals.example/", True),
+ (("-bhttps://cluster.example/",), "https://cluster.example/", True),
+ (("--port", "1414"), "http://localhost:1414/", False),
+ (("--port=1515",), "http://localhost:1515/", False),
+ (("-p=1616",), "http://localhost:1616/", False),
+ (("-p1717",), "http://localhost:1717/", False),
+ )
+ for args, expected_origin, environment_set in cases:
+ with self.subTest(args=args):
+ self.log.unlink(missing_ok=True)
+ environment_overrides = (
+ {"HG_DOC_SITE_ORIGIN": "https://environment.example/"}
+ if environment_set
+ else None
+ )
+ calls = self.run_wrapper(
+ "server",
+ *args,
+ environment_overrides=environment_overrides,
+ )
+ config_args = calls[0]["args"]
+ self.assertEqual(
+ config_args[config_args.index("--site-origin") + 1],
+ expected_origin,
+ )
+
+ def test_build_enforces_the_warning_strict_production_contract(self) -> None:
+ calls = self.run_wrapper("build", "--destination", "custom-public")
+ args = calls[1]["args"]
+ self.assertNotIn("build", args)
+ for required in (
+ "--cleanDestinationDir",
+ "--gc",
+ "--minify",
+ "--environment",
+ "production",
+ "--printPathWarnings",
+ "--printI18nWarnings",
+ "--panicOnWarning",
+ ):
+ self.assertIn(required, args)
+ self.assertEqual(args[-2:], ["--destination", "custom-public"])
+
+ def test_documented_preview_and_build_use_the_wrapper(self) -> None:
+ for relative in ("README.md", "contribution.md"):
+ text = (ROOT / relative).read_text(encoding="utf-8")
+ self.assertNotIn("hugo server", text)
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ contribution = (ROOT / "contribution.md").read_text(encoding="utf-8")
+ self.assertGreaterEqual(readme.count("scripts/hugo.sh server"), 4)
+ self.assertGreaterEqual(readme.count("scripts/hugo.sh build"), 2)
+ self.assertIn("scripts/hugo.sh server", contribution)
+ self.assertIn("scripts/hugo.sh build", contribution)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_validate_site_output.py b/scripts/test_validate_site_output.py
index 55348d369..7a71ae696 100644
--- a/scripts/test_validate_site_output.py
+++ b/scripts/test_validate_site_output.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import importlib.util
+import json
import pathlib
import subprocess
import sys
@@ -150,6 +151,98 @@ class SiteOutputSecurityTest(unittest.TestCase):
],
)
+ def test_error_document_paths_follow_all_manifest_versions(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ manifest = {
+ "versions": [
+ {"id": "latest", "publishPath": ""},
+ {"id": "1.7", "publishPath": "versions/1.7"},
+ {"id": "1.5", "publishPath": "versions/1.5"},
+ {"id": "1.3", "publishPath": "versions/1.3"},
+ {"id": "1.0", "publishPath": "versions/1.0"},
+ ]
+ }
+ metadata = root / "build-metadata"
+ metadata.mkdir()
+ (metadata / "versions.json").write_text(
+ json.dumps(manifest), encoding="utf-8"
+ )
+
+ self.assertEqual(
+ VALIDATOR.error_document_paths(root),
+ {
+ "404.html",
+ "cn/404.html",
+ "versions/1.7/404.html",
+ "versions/1.7/cn/404.html",
+ "versions/1.5/404.html",
+ "versions/1.5/cn/404.html",
+ "versions/1.3/404.html",
+ "versions/1.3/cn/404.html",
+ "versions/1.0/404.html",
+ "versions/1.0/cn/404.html",
+ },
+ )
+
+ def test_all_modes_reject_old_version_error_page_seo(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ manifest = {
+ "versions": [
+ {"id": "latest", "publishPath": ""},
+ {"id": "1.3", "publishPath": "versions/1.3"},
+ {"id": "1.0", "publishPath": "versions/1.0"},
+ ]
+ }
+ metadata = root / "build-metadata"
+ metadata.mkdir()
+ (metadata / "versions.json").write_text(
+ json.dumps(manifest), encoding="utf-8"
+ )
+ unsafe = (
+ '<meta name="robots" content="index, follow">'
+ '<link rel="canonical" href="https://example.com/404.html">'
+ '<link rel="alternate" hreflang="en-US" '
+ 'href="https://example.com/404.html">'
+ )
+ for version in ("1.3", "1.0"):
+ for language in ("", "cn/"):
+ page = root / f"versions/{version}/{language}404.html"
+ page.parent.mkdir(parents=True, exist_ok=True)
+ page.write_text(unsafe, encoding="utf-8")
+
+ for extra_args in (["--security-only"], []):
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ *extra_args,
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+ for version in ("1.3", "1.0"):
+ for language in ("", "cn/"):
+ page_name = f"versions/{version}/{language}404.html"
+ with self.subTest(mode=extra_args, page_name=page_name):
+ self.assertIn(
+ f"{page_name}: expected one robots noindex,nofollow",
+ result.stdout,
+ )
+ self.assertIn(
+ f"{page_name}: error document must not declare canonical",
+ result.stdout,
+ )
+ self.assertIn(
+ f"{page_name}: error document must not declare hreflang",
+ result.stdout,
+ )
+
def test_nested_content_404_is_not_an_error_document_exception(self) -> None:
parser = parse(
'<meta name="robots" content="index, follow">'
@@ -264,6 +357,30 @@ class SiteOutputSecurityTest(unittest.TestCase):
],
)
+ def test_duplicate_attributes_are_rejected_case_insensitively(self) -> None:
+ parser = parse(
+ '<img src="/safe.png" SRC="https://evil.example/image.png">'
+ '<svg><use xlink:href="#safe" XLINK:HREF="https://evil.example/x">'
+ "</use></svg>"
+ )
+ self.assertEqual(
+ parser.authored_violations,
+ [
+ "duplicate src attribute on <img>",
+ "duplicate xlink:href attribute on <use>",
+ ],
+ )
+ errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
+ self.assertIn(
+ "index.html: unsafe content markup: duplicate src attribute on <img>",
+ errors,
+ )
+ self.assertIn(
+ "index.html: unsafe content markup: duplicate xlink:href attribute "
+ "on <use>",
+ errors,
+ )
+
def test_exact_oink_diagram_source_is_allowed_inside_content(self) -> None:
parser = parse(
'<main><script type="application/json" data-td-diagram-source>'
@@ -307,6 +424,256 @@ class SiteOutputSecurityTest(unittest.TestCase):
],
)
+ def test_authorityless_and_ambiguous_http_links_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ docs = root / "docs/introduction"
+ docs.mkdir(parents=True)
+ (docs / "index.html").write_text("target", encoding="utf-8")
+ (root / "index.html").write_text(
+ (
+ '<a href="https:///docs/introduction/">triple</a>'
+ '<a href="https:////docs/introduction/">quad</a>'
+ '<a href="http:docs/introduction/">opaque</a>'
+ '<a href="/docs/introduction/\t">control</a>'
+ '<a href="/docs\\introduction/">backslash</a>'
+ '<img srcset="https:///docs/introduction/ 1x">'
+ '<object data="https:///docs/introduction/"></object>'
+ '<video poster="https:///docs/introduction/"></video>'
+ '<style>.hero{background:url(https:///docs/introduction/)}</style>'
+ '<a href="javascript:alert(1)">active link</a>'
+ '<script src="javascript:alert(1)"></script>'
+ '<iframe src="javascript:alert(1)"></iframe>'
+ '<object data="javascript:alert(1)"></object>'
+ '<a href="mailto:dev@example.com">allowed contact</a>'
+ ),
+ encoding="utf-8",
+ )
+ (root / "boundary.html").write_text(
+ (
+ '<form action="https://evil.example/collect">'
+ '<button formaction="https://evil.example/button">go</button>'
+ '<input formaction="https://evil.example/input">'
+ "</form>"
+ '<a href="/docs/" ping="https://evil.example/ping">ping</a>'
+ '<area href="/docs/" ping="https://evil.example/ping">'
+ '<base href="https://evil.example/">'
+ ),
+ encoding="utf-8",
+ )
+ (root / "site.css").write_text(
+ ".hero{background:url(https:///docs/introduction/)}",
+ encoding="utf-8",
+ )
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ "--security-only",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+ self.assertIn("HTTP(S) URL has no authority", result.stdout)
+ self.assertGreaterEqual(
+ result.stdout.count("HTTP(S) URL has no authority"),
+ 6,
+ result.stdout,
+ )
+ self.assertIn("unsafe whitespace/control URL", result.stdout)
+ self.assertIn("unsafe backslash URL", result.stdout)
+ self.assertIn("forbidden URL scheme", result.stdout)
+ self.assertIn("site.css", result.stdout)
+
+ def test_security_only_rejects_unreviewed_request_attributes(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ (root / "index.html").write_text(
+ (
+ '<form action="javascript:alert(1)">'
+ '<button formaction="javascript:alert(1)">go</button>'
+ '<input formaction="https://evil.example/submit">'
+ "</form>"
+ '<a href="/docs/" ping="https://evil.example/ping">ping</a>'
+ '<area href="/docs/" ping="https://evil.example/ping">'
+ '<base href="https://evil.example/">'
+ '<svg><use href="javascript:alert(1)"></use></svg>'
+ ),
+ encoding="utf-8",
+ )
+ (root / "external-boundary.html").write_text(
+ (
+ '<form action="https://evil.example/collect">'
+ '<button formaction="https://evil.example/button">go</button>'
+ '<input formaction="https://evil.example/input">'
+ "</form>"
+ '<a href="/docs/" ping="https://evil.example/ping">ping</a>'
+ '<area href="/docs/" ping="https://evil.example/ping">'
+ '<base href="https://evil.example/">'
+ '<link rel="prefetch" href="https://evil.example/payload">'
+ '<link rel="prerender" href="https://evil.example/page">'
+ '<link rel="preconnect" href="https://evil.example/">'
+ '<link rel="dns-prefetch" href="https://evil.example/">'
+ '<link rel="preload" as="image" '
+ 'imagesrcset="https://evil.example/preload.png 1x">'
+ '<svg><use xlink:href="https://evil.example/icon.svg"></use>'
+ '<image xlink:href="https://evil.example/image.svg"></image></svg>'
+ '<img src="/local.png" '
+ 'attributionsrc="https://evil.example/attribute">'
+ ),
+ encoding="utf-8",
+ )
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ "--security-only",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+ self.assertIn("forbidden ping attribute", result.stdout)
+ self.assertIn("forbidden base[href]", result.stdout)
+ self.assertIn("forbidden URL scheme in form[action]", result.stdout)
+ self.assertIn("forbidden URL scheme in button[formaction]", result.stdout)
+ self.assertIn("external active resource", result.stdout)
+ self.assertIn(
+ "external active resource is forbidden <link> href: "
+ "https://evil.example/payload",
+ result.stdout,
+ )
+ self.assertIn(
+ "external active resource is forbidden <link> href: "
+ "https://evil.example/page",
+ result.stdout,
+ )
+ self.assertIn(
+ "external active resource is forbidden <use> xlink:href",
+ result.stdout,
+ )
+ self.assertIn(
+ "external active resource is forbidden <link> imagesrcset",
+ result.stdout,
+ )
+ self.assertIn("forbidden attributionsrc attribute", result.stdout)
+ self.assertIn("forbidden URL scheme in use[href]", result.stdout)
+
+ def test_runtime_url_attributes_enter_security_and_target_validation(self) -> None:
+ fragment = (
+ '<main data-td-index-src="/missing-index.json" '
+ 'data-td-url="/missing-runtime/" '
+ 'data-td-action-url="/missing-action/" '
+ 'data-td-image-zoom="https://images.example.com/zoom.png"></main>'
+ )
+ parser = parse(fragment)
+ for attribute, url in (
+ ("data-td-index-src", "/missing-index.json"),
+ ("data-td-url", "/missing-runtime/"),
+ ("data-td-action-url", "/missing-action/"),
+ ("data-td-image-zoom", "https://images.example.com/zoom.png"),
+ ):
+ self.assertIn((attribute, url), parser.urls)
+ errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
+ self.assertIn(
+ "index.html: image URL is outside ASF CSP <main> "
+ "data-td-image-zoom: https://images.example.com/zoom.png",
+ errors,
+ )
+
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ (root / "index.html").write_text(fragment, encoding="utf-8")
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 1)
+ self.assertIn(
+ "broken internal data-td-index-src /missing-index.json",
+ result.stdout,
+ )
+ self.assertIn(
+ "broken internal data-td-url /missing-runtime/",
+ result.stdout,
+ )
+ self.assertIn(
+ "broken internal data-td-action-url /missing-action/",
+ result.stdout,
+ )
+
+ def test_security_only_rejects_data_resource_schemes(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ data_image = "data:image/png;base64,AAAA"
+ (root / "index.html").write_text(
+ (
+ f'<img src="{data_image}" srcset="{data_image} 1x">'
+ f'<picture><source srcset="{data_image} 1x"></picture>'
+ f'<video poster="{data_image}"></video>'
+ f'<style>.hero{{background:url({data_image})}}</style>'
+ ),
+ encoding="utf-8",
+ )
+ (root / "site.css").write_text(
+ f".hero{{background:url({data_image})}}",
+ encoding="utf-8",
+ )
+
+ passive = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ "--security-only",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(passive.returncode, 1)
+ self.assertIn("forbidden URL scheme in img[src]", passive.stdout)
+ self.assertIn("forbidden URL scheme in inline CSS", passive.stdout)
+ self.assertIn("forbidden URL scheme in CSS resource", passive.stdout)
+
+ (root / "active.html").write_text(
+ f'<script src="{data_image}"></script>',
+ encoding="utf-8",
+ )
+ rejected = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ "--security-only",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(rejected.returncode, 1)
+ self.assertIn("forbidden URL scheme in script[src]", rejected.stdout)
+
def test_css_http_resources_are_detected(self) -> None:
parser = parse(
'<style>@import "http://example.com/base.css"; '
@@ -336,6 +703,274 @@ class SiteOutputSecurityTest(unittest.TestCase):
],
)
+ def test_css_tokenizer_covers_escaped_and_image_set_resources(self) -> None:
+ source = (
+ '@\\69mport "https://evil.example/import with space.css";'
+ ".quoted { background: url('https://evil.example/image with space.png') }"
+ ".escaped { background: u\\72l(h\\74tps://evil.example/escaped.png) }"
+ ".set { background-image: image-set("
+ '"https://evil.example/one.png" 1x, '
+ "url(https://evil.example/two.png) 2x) }"
+ ".webkit { background-image: -webkit-image-set("
+ '"https://evil.example/three.png" 1x) }'
+ )
+ self.assertEqual(
+ VALIDATOR.css_resource_urls(source),
+ [
+ "https://evil.example/import with space.css",
+ "https://evil.example/image with space.png",
+ "https://evil.example/escaped.png",
+ "https://evil.example/one.png",
+ "https://evil.example/two.png",
+ "https://evil.example/three.png",
+ ],
+ )
+
+ def test_css_request_surfaces_share_the_same_security_policy(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ escaped = "h\\74tps://evil.example/request.png"
+ (root / "index.html").write_text(
+ (
+ f'<div style="background:image-set("{escaped}" 1x)">'
+ "</div>"
+ f"<style>.hero{{background:url('{escaped}')}}</style>"
+ ),
+ encoding="utf-8",
+ )
+ (root / "site.css").write_text(
+ f".hero{{background:-webkit-image-set(\"{escaped}\" 1x)}}",
+ encoding="utf-8",
+ )
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ "--security-only",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+ self.assertGreaterEqual(
+ result.stdout.count("external CSS resource is forbidden"),
+ 3,
+ result.stdout,
+ )
+
+ def test_css_internal_resources_resolve_from_stylesheet_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ (root / "index.html").write_text("<main>safe</main>", encoding="utf-8")
+ (root / "scss").mkdir()
+ (root / "img").mkdir()
+ (root / "img/present.svg").write_text("<svg/>", encoding="utf-8")
+ (root / "scss/site.css").write_text(
+ (
+ '.present{background:url("../img/present.svg")}'
+ '.scoped{background:url("https://hugegraph.apache.org/'
+ 'versions/1.7/img/present.svg")}'
+ '.missing{background:url("../img/missing.svg")}'
+ '@import "./missing.css";'
+ '.escape{background:url("../../escape.svg")}'
+ '.fragment{filter:url("#local-filter")}'
+ ),
+ encoding="utf-8",
+ )
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/versions/1.7/",
+ "--security-only",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+ self.assertNotIn("present.svg", result.stdout)
+ self.assertIn(
+ "broken internal CSS resource ../img/missing.svg -> img/missing.svg",
+ result.stdout,
+ )
+ self.assertIn(
+ "broken internal CSS resource ./missing.css -> scss/missing.css",
+ result.stdout,
+ )
+ self.assertIn(
+ "unsafe internal CSS resource ../../escape.svg: "
+ "escapes output directory",
+ result.stdout,
+ )
+ self.assertNotIn("local-filter", result.stdout)
+
+ def test_parser_covers_legacy_and_svg_request_surfaces(self) -> None:
+ parser = parse(
+ '<iframe srcdoc="<script>run()</script>"></iframe>'
+ '<frame src="https://evil.example/frame">'
+ '<body background="https://images.example.com/body.png">'
+ '<table background="/table.png"><tr>'
+ '<th background="/head.png">h</th>'
+ '<td background="/cell.png">c</td></tr></table>'
+ '<svg>'
+ '<a xlink:href="https://example.com/docs">docs</a>'
+ '<image xlink:href="https://www.apache.org/image.svg"></image>'
+ '<feImage href="https://www.apache.org/filter.svg"></feImage>'
+ '<mpath xlink:href="https://evil.example/path.svg#p"></mpath>'
+ '<pattern href="https://evil.example/pattern.svg#p"></pattern>'
+ '<path fill="url(https://evil.example/paint.svg#gradient)"></path>'
+ "</svg>"
+ )
+ self.assertIn("forbidden iframe[srcdoc]", parser.authored_violations)
+ self.assertIn(
+ ("a", "xlink:href", "https://example.com/docs"),
+ parser.navigation_urls,
+ )
+ self.assertIn(
+ ("mpath", "xlink:href", "https://evil.example/path.svg#p"),
+ parser.resources,
+ )
+ self.assertIn(
+ ("pattern", "href", "https://evil.example/pattern.svg#p"),
+ parser.resources,
+ )
+ self.assertIn(
+ ("image", "xlink:href", "https://www.apache.org/image.svg"),
+ parser.image_urls,
+ )
+ self.assertIn(
+ ("feimage", "href", "https://www.apache.org/filter.svg"),
+ parser.image_urls,
+ )
+ self.assertIn(
+ "https://evil.example/paint.svg#gradient",
+ [
+ url
+ for source in parser.inline_css_sources
+ for url in VALIDATOR.css_resource_urls(source)
+ ],
+ )
+ self.assertIn(
+ "external active resource is forbidden <frame> src",
+ "\n".join(
+ VALIDATOR.document_security_errors(parser, "index.html", BASE)
+ ),
+ )
+
+ def test_media_sources_are_not_treated_as_csp_images(self) -> None:
+ parser = parse(
+ '<picture><source srcset="https://www.apache.org/image.webp 1x">'
+ '<img src="https://www.apache.org/image.png"></picture>'
+ '<video><source src="https://www.apache.org/video.mp4" '
+ 'srcset="https://www.apache.org/video-hd.mp4 2x"></video>'
+ '<audio><source src="https://www.apache.org/audio.mp3"></audio>'
+ '<video></audio>'
+ '<source src="https://www.apache.org/mismatched.mp4"></video>'
+ )
+ errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
+ self.assertEqual(
+ sum("external active resource" in error for error in errors),
+ 4,
+ errors,
+ )
+ self.assertFalse(
+ any("image.webp" in error or "image.png" in error for error in errors),
+ errors,
+ )
+
+ def test_authored_content_markers_survive_premature_container_closures(
+ self,
+ ) -> None:
+ parser = parse(
+ '<main><article><template data-hg-authored-content="start"></template>'
+ '</article></main><script src="/escaped.js"></script>'
+ '<template data-hg-authored-content="end"></template>'
+ )
+ self.assertIn("authored <script>", parser.authored_violations)
+
+ spoofed = parse(
+ '<template data-hg-authored-content="start"></template>'
+ '<template data-hg-authored-content="end"></template>'
+ '<script src="/escaped.js"></script>'
+ '<template data-hg-authored-content="end"></template>'
+ )
+ self.assertIn(
+ "unexpected authored-content end marker",
+ spoofed.authored_violations,
+ )
+ self.assertTrue(
+ VALIDATOR.document_security_errors(spoofed, "index.html", BASE)
+ )
+
+ sequential = parse(
+ '<template data-hg-authored-content="start"></template>'
+ "<p>one</p>"
+ '<template data-hg-authored-content="end"></template>'
+ '<template data-hg-authored-content="start"></template>'
+ "<p>two</p>"
+ '<template data-hg-authored-content="end"></template>'
+ )
+ self.assertEqual(
+ VALIDATOR.document_security_errors(sequential, "print.html", BASE),
+ [],
+ )
+
+ def test_link_rel_is_fail_closed_for_request_capable_and_unknown_values(self) -> None:
+ parser = parse(
+ '<link rel="canonical" href="https://hugegraph-oink.staged.apache.org/">'
+ '<link rel="alternate" hreflang="zh-CN" '
+ 'href="https://hugegraph-oink.staged.apache.org/cn/">'
+ '<link rel="mask-icon" href="https://evil.example/mask.svg">'
+ '<link rel="apple-touch-startup-image" '
+ 'href="https://evil.example/start.png">'
+ '<link rel="future-browser-fetch" href="https://evil.example/future">'
+ )
+ errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
+ self.assertEqual(len(errors), 3, errors)
+ self.assertTrue(all("external active resource" in error for error in errors))
+
+ def test_srcset_and_imagesrcset_candidates_are_internal_targets(self) -> None:
+ fragment = (
+ '<img srcset="/present.png 1x, /missing.png 2x">'
+ '<link rel="preload" as="image" '
+ 'imagesrcset="/present-link.png 1x, /missing-link.png 2x">'
+ )
+ parser = parse(fragment)
+ self.assertIn(("srcset", "/missing.png"), parser.urls)
+ self.assertIn(("imagesrcset", "/missing-link.png"), parser.urls)
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = pathlib.Path(temp_name)
+ (root / "index.html").write_text(fragment, encoding="utf-8")
+ (root / "present.png").write_bytes(b"present")
+ (root / "present-link.png").write_bytes(b"present")
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(VALIDATOR_PATH),
+ str(root),
+ "https://hugegraph.apache.org/",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 1)
+ self.assertIn(
+ "broken internal srcset /missing.png -> missing.png",
+ result.stdout,
+ )
+ self.assertIn(
+ "broken internal imagesrcset /missing-link.png -> missing-link.png",
+ result.stdout,
+ )
+
def test_asf_csp_image_sources_are_allowed(self) -> None:
allowed = [
"/img/local.svg",
diff --git a/scripts/test_versioning.py b/scripts/test_versioning.py
index 0e47f6836..f39e76881 100644
--- a/scripts/test_versioning.py
+++ b/scripts/test_versioning.py
@@ -26,6 +26,10 @@ ALLOWED_PATHS = {
"/versions/1.7/cn/docs",
"/versions/1.5/docs",
"/versions/1.5/cn/docs",
+ "/versions/1.3/docs",
+ "/versions/1.3/cn/docs",
+ "/versions/1.0/docs",
+ "/versions/1.0/cn/docs",
}
@@ -154,8 +158,36 @@ class VersionUrlTest(unittest.TestCase):
(artifact / ".version.json").write_text(
json.dumps(metadata), encoding="utf-8"
)
+ social = artifact / "img/social/fallback.png"
+ social.parent.mkdir(parents=True)
+ social.write_bytes(b"png")
(artifact / "index.html").write_text(
- '<nav id="TableOfContents"></nav>', encoding="utf-8"
+ '<meta property="og:image" content="/img/social/fallback.png">'
+ '<meta name="twitter:image" content="/img/social/fallback.png">'
+ '<nav id="TableOfContents"></nav>',
+ encoding="utf-8",
+ )
+ en_llms = artifact / "docs/llms-full.txt"
+ cn_llms = artifact / "cn/docs/llms-full.txt"
+ en_llms.parent.mkdir(parents=True)
+ cn_llms.parent.mkdir(parents=True)
+ en_llms.write_text(
+ (
+ f"Source: {ORIGIN}docs/index.md\n\n"
+ "LLMS index: [llms.txt](/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ cn_llms.write_text(
+ (
+ f"Source: {ORIGIN}cn/docs/index.md\n\n"
+ "LLMS 索引: [llms.txt](/cn/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ (artifact / "llms.txt").write_text("English index\n", encoding="utf-8")
+ (artifact / "cn/llms.txt").write_text(
+ "中文索引\n", encoding="utf-8"
)
contract = temp / "url-contract.json"
contract.write_text(
@@ -177,9 +209,36 @@ class VersionUrlTest(unittest.TestCase):
def test_version_urls_preserve_language_and_order(self) -> None:
manifest = {
"versions": [
- {"id": "latest", "name": "latest", "publishPath": ""},
- {"id": "1.7", "name": "1.7", "publishPath": "versions/1.7"},
- {"id": "1.5", "name": "1.5", "publishPath": "versions/1.5"},
+ {
+ "id": "latest",
+ "name": "latest",
+ "publishPath": "",
+ "archived": False,
+ },
+ {
+ "id": "1.7",
+ "name": "1.7",
+ "publishPath": "versions/1.7",
+ "archived": True,
+ },
+ {
+ "id": "1.5",
+ "name": "1.5",
+ "publishPath": "versions/1.5",
+ "archived": True,
+ },
+ {
+ "id": "1.3",
+ "name": "1.3",
+ "publishPath": "versions/1.3",
+ "archived": True,
+ },
+ {
+ "id": "1.0",
+ "name": "1.0",
+ "publishPath": "versions/1.0",
+ "archived": True,
+ },
]
}
self.assertEqual(
@@ -188,6 +247,8 @@ class VersionUrlTest(unittest.TestCase):
f"{ORIGIN}docs/",
f"{ORIGIN}versions/1.7/docs/",
f"{ORIGIN}versions/1.5/docs/",
+ f"{ORIGIN}versions/1.3/docs/",
+ f"{ORIGIN}versions/1.0/docs/",
],
)
self.assertEqual(
@@ -196,6 +257,8 @@ class VersionUrlTest(unittest.TestCase):
f"{ORIGIN}cn/docs/",
f"{ORIGIN}versions/1.7/cn/docs/",
f"{ORIGIN}versions/1.5/cn/docs/",
+ f"{ORIGIN}versions/1.3/cn/docs/",
+ f"{ORIGIN}versions/1.0/cn/docs/",
],
)
self.assertEqual(
@@ -213,6 +276,401 @@ class VersionUrlTest(unittest.TestCase):
f"{ORIGIN}cn/docs/",
)
+ def test_direct_hugo_config_is_derived_from_five_version_manifest(self) -> None:
+ manifest = versioning.load_manifest(versioning.ROOT / "versions.json")
+ latest = manifest["versions"][0]
+ config = versioning.derived_version_config(manifest, latest, ORIGIN)
+ expected = ["latest", "1.7", "1.5", "1.3", "1.0"]
+ self.assertEqual(
+ [item["version"] for item in config["params"]["versions"]], expected
+ )
+ for language in ("en", "cn"):
+ self.assertEqual(
+ [
+ item["version"]
+ for item in config["languages"][language]["params"]["versions"]
+ ],
+ expected,
+ )
+ self.assertNotIn("1.2", json.dumps(config))
+
+ def test_shell_static_assets_include_all_shared_resources(self) -> None:
+ self.assertIn(
+ "img/social/hugegraph-default.png",
+ versioning.SHELL_STATIC_FILES,
+ )
+ self.assertTrue(
+ (
+ versioning.ROOT
+ / "static/img/social/hugegraph-default.png"
+ ).is_file()
+ )
+ self.assertIn("img/bootstrap-controls", versioning.SHELL_STATIC_DIRS)
+ self.assertIn("img/home", versioning.SHELL_STATIC_DIRS)
+ controls = (
+ versioning.ROOT / "static/img/bootstrap-controls"
+ )
+ self.assertEqual(len(list(controls.glob("*.svg"))), 20)
+
+ with tempfile.TemporaryDirectory() as temp_name:
+ assembly = Path(temp_name)
+ versioning.overlay_shell(
+ assembly,
+ historical=True,
+ origin=ORIGIN,
+ )
+ copied = assembly / "static/img/bootstrap-controls"
+ self.assertEqual(
+ sorted(path.name for path in copied.glob("*.svg")),
+ sorted(path.name for path in controls.glob("*.svg")),
+ )
+ self.assertEqual(
+ sorted(
+ path.name
+ for path in (assembly / "static/img/home").glob("*.jpg")
+ ),
+ ["hugegraph-hero-1920.jpg", "hugegraph-hero-960.jpg"],
+ )
+
+ def test_llms_full_contract_requires_latest_locales_and_forbids_history(
+ self,
+ ) -> None:
+ manifest = versioning.load_manifest(versioning.ROOT / "versions.json")
+ latest = manifest["versions"][0]
+ archived = manifest["versions"][-1]
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = Path(temp_name)
+ en = root / "docs/llms-full.txt"
+ cn = root / "cn/docs/llms-full.txt"
+ en.parent.mkdir(parents=True)
+ cn.parent.mkdir(parents=True)
+ en.write_text(
+ (
+ "Source: https://hugegraph.apache.org/docs/index.md\n\n"
+ "LLMS index: [llms.txt](/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ cn.write_text(
+ (
+ "Source: https://hugegraph.apache.org/cn/docs/index.md\n\n"
+ "LLMS 索引: [llms.txt](/cn/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ versioning.validate_llms_full_outputs(root, latest, ORIGIN)
+
+ cn.unlink()
+ with self.assertRaisesRegex(SystemExit, "Chinese LLMSFULL"):
+ versioning.validate_llms_full_outputs(root, latest, ORIGIN)
+ cn.write_text(
+ (
+ "Source: https://hugegraph.apache.org/cn/docs/index.md\n\n"
+ "LLMS index: [llms.txt](/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ with self.assertRaisesRegex(SystemExit, "locale contract"):
+ versioning.validate_llms_full_outputs(root, latest, ORIGIN)
+
+ with self.assertRaisesRegex(SystemExit, "historical artifact"):
+ versioning.validate_llms_full_outputs(root, archived, ORIGIN)
+
+ def test_llms_full_contract_validates_every_source_line(self) -> None:
+ latest = versioning.load_manifest(
+ versioning.ROOT / "versions.json"
+ )["versions"][0]
+ invalid_sources = (
+ "https://hugegraph.apache.org/cn/docs/config/index.md",
+ "https://example.com/docs/config/index.md",
+ "https://hugegraph.apache.org/versions/1.7/docs/config/index.md",
+ "javascript:alert(1)",
+ "https://hugegraph.apache.org/docs/config/index.md?",
+ "https://hugegraph.apache.org/docs/config/index.md#",
+ "https://hugegraph.apache.org/docs/%63onfig/index.md",
+ "https://hugegraph.apache.org/docs/./config/index.md",
+ "https://[invalid/docs/config/index.md",
+ "",
+ "https://hugegraph.apache.org/docs/index.md",
+ )
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = Path(temp_name)
+ en = root / "docs/llms-full.txt"
+ cn = root / "cn/docs/llms-full.txt"
+ en.parent.mkdir(parents=True)
+ cn.parent.mkdir(parents=True)
+ cn.write_text(
+ (
+ "Source: https://hugegraph.apache.org/cn/docs/index.md\n\n"
+ "LLMS 索引: [llms.txt](/cn/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ for invalid in invalid_sources:
+ with self.subTest(source=invalid):
+ en.write_text(
+ (
+ "Source: https://hugegraph.apache.org/docs/index.md\n"
+ f"Source: {invalid}\n\n"
+ "LLMS index: [llms.txt](/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ with self.assertRaisesRegex(
+ SystemExit, "English LLMSFULL source"
+ ):
+ versioning.validate_llms_full_outputs(
+ root, latest, ORIGIN
+ )
+
+ def test_llms_full_contract_requires_canonical_source_first(self) -> None:
+ latest = versioning.load_manifest(
+ versioning.ROOT / "versions.json"
+ )["versions"][0]
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = Path(temp_name)
+ en = root / "docs/llms-full.txt"
+ cn = root / "cn/docs/llms-full.txt"
+ en.parent.mkdir(parents=True)
+ cn.parent.mkdir(parents=True)
+ en.write_text(
+ (
+ "Source: https://hugegraph.apache.org/docs/config/index.md\n"
+ "Source: https://hugegraph.apache.org/docs/index.md\n\n"
+ "LLMS index: [llms.txt](/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ cn.write_text(
+ (
+ "Source: https://hugegraph.apache.org/cn/docs/index.md\n\n"
+ "LLMS 索引: [llms.txt](/cn/llms.txt)\n"
+ ),
+ encoding="utf-8",
+ )
+ with self.assertRaisesRegex(
+ SystemExit, "English LLMSFULL canonical source"
+ ):
+ versioning.validate_llms_full_outputs(root, latest, ORIGIN)
+
+ def test_social_image_metadata_requires_safe_matching_local_images(
+ self,
+ ) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = Path(temp_name)
+ image = root / "img/social/fallback.png"
+ image.parent.mkdir(parents=True)
+ image.write_bytes(b"png")
+ non_image = root / "img/social/not-image.html"
+ non_image.write_text("not an image", encoding="utf-8")
+
+ def document(og: str, twitter: str) -> versioning.DocumentParser:
+ parser = versioning.DocumentParser()
+ parser.feed(
+ f'<meta property="og:image" content="{og}">'
+ f'<meta name="twitter:image" content="{twitter}">'
+ )
+ return parser
+
+ for value in (
+ "https://hugegraph.apache.org/img/social/fallback.png",
+ "/img/social/fallback.png",
+ ):
+ versioning.validate_social_image_metadata(
+ document(value, value),
+ "docs/index.html",
+ root,
+ ORIGIN,
+ )
+
+ invalid_pairs = (
+ ("mailto:test@example.com", "mailto:test@example.com"),
+ ("tel:+123", "tel:+123"),
+ ("javascript:alert(1)", "javascript:alert(1)"),
+ ("?image=/img/social/fallback.png", "?image=/img/social/fallback.png"),
+ ("#fallback.png", "#fallback.png"),
+ (
+ "http://hugegraph.apache.org/img/social/fallback.png",
+ "http://hugegraph.apache.org/img/social/fallback.png",
+ ),
+ (
+ "https://example.com/img/social/fallback.png",
+ "https://example.com/img/social/fallback.png",
+ ),
+ ("/img/social/missing.png", "/img/social/missing.png"),
+ ("/img/social/not-image.html", "/img/social/not-image.html"),
+ (
+ "/img/social/fallback.png",
+ "https://hugegraph.apache.org/img/social/fallback.png",
+ ),
+ )
+ for og, twitter in invalid_pairs:
+ with self.subTest(og=og, twitter=twitter), self.assertRaises(
+ SystemExit
+ ):
+ versioning.validate_social_image_metadata(
+ document(og, twitter),
+ "docs/index.html",
+ root,
+ ORIGIN,
+ )
+
+ def test_social_image_metadata_allows_only_redirects_to_omit_both_tags(
+ self,
+ ) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ root = Path(temp_name)
+ empty = versioning.DocumentParser()
+ empty.feed('<meta http-equiv="refresh" content="0; url=/docs/">')
+
+ versioning.validate_social_image_metadata(
+ empty,
+ "docs/alias/index.html",
+ root,
+ ORIGIN,
+ allow_missing=True,
+ )
+
+ with self.assertRaisesRegex(
+ SystemExit, "social image metadata is missing or duplicated"
+ ):
+ versioning.validate_social_image_metadata(
+ empty,
+ "docs/index.html",
+ root,
+ ORIGIN,
+ )
+
+ partial = versioning.DocumentParser()
+ partial.feed(
+ '<meta http-equiv="refresh" content="0; url=/docs/">'
+ '<meta property="og:image" content="/img/social/fallback.png">'
+ )
+ with self.assertRaisesRegex(
+ SystemExit, "social image metadata is missing or duplicated"
+ ):
+ versioning.validate_social_image_metadata(
+ partial,
+ "docs/alias/index.html",
+ root,
+ ORIGIN,
+ allow_missing=True,
+ )
+
+ def test_temporary_manifest_drives_prepare_config_and_route_order(self) -> None:
+ manifest_data = {
+ "schemaVersion": 1,
+ "repository": "https://github.com/apache/hugegraph-doc.git",
+ "versions": [
+ {
+ "id": "latest",
+ "name": "Next",
+ "ref": "main-docs",
+ "publishPath": "",
+ "archived": False,
+ "githubBranch": "main-docs",
+ },
+ {
+ "id": "2.0",
+ "name": "2.0",
+ "ref": "release-2-docs",
+ "publishPath": "versions/stable-two",
+ "archived": True,
+ "githubBranch": "release-2-docs",
+ },
+ {
+ "id": "1.9",
+ "name": "1.9",
+ "ref": "release-19-docs",
+ "publishPath": "versions/stable-nineteen",
+ "archived": True,
+ "githubBranch": "release-19-docs",
+ },
+ ],
+ }
+ with tempfile.TemporaryDirectory() as temp_name:
+ temp = Path(temp_name)
+ manifest_path = temp / "versions.json"
+ manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
+ manifest = versioning.load_manifest(manifest_path)
+ resolved_path = temp / "resolved.json"
+ args = argparse.Namespace(
+ manifest=manifest_path,
+ local=False,
+ latest_sha="a" * 40,
+ select=None,
+ output=resolved_path,
+ )
+ remote_shas = {
+ "release-2-docs": "b" * 40,
+ "release-19-docs": "c" * 40,
+ }
+ with (
+ mock.patch.object(versioning, "run", return_value="a" * 40),
+ mock.patch.object(
+ versioning,
+ "resolve_remote",
+ side_effect=lambda _repository, ref: remote_shas[ref],
+ ),
+ ):
+ versioning.prepare(args)
+ resolved = json.loads(resolved_path.read_text(encoding="utf-8"))
+ self.assertEqual(
+ [entry["id"] for entry in resolved["include"]],
+ ["latest", "2.0", "1.9"],
+ )
+ self.assertEqual(
+ [entry["ref"] for entry in resolved["versions"]],
+ ["main-docs", "release-2-docs", "release-19-docs"],
+ )
+
+ config = versioning.derived_version_config(
+ manifest, manifest["versions"][0], ORIGIN
+ )
+ self.assertEqual(
+ [entry["version"] for entry in config["params"]["versions"]],
+ ["latest", "2.0", "1.9"],
+ )
+ self.assertEqual(
+ [entry["url"] for entry in config["params"]["versions"]],
+ [
+ f"{ORIGIN}docs/",
+ f"{ORIGIN}versions/stable-two/docs/",
+ f"{ORIGIN}versions/stable-nineteen/docs/",
+ ],
+ )
+ config_path = temp / "version-config.json"
+ versioning.render_config(
+ argparse.Namespace(
+ manifest=manifest_path,
+ version=None,
+ site_origin=ORIGIN,
+ historical_origin=None,
+ output=config_path,
+ )
+ )
+ rendered_config = json.loads(config_path.read_text(encoding="utf-8"))
+ self.assertEqual(rendered_config["params"]["version"], "latest")
+ self.assertEqual(
+ rendered_config["params"]["github_branch"], "main-docs"
+ )
+
+ roots = {}
+ for entry in manifest["versions"]:
+ root = temp / entry["id"]
+ roots[entry["id"]] = root
+ for relative in ("docs/guide", "cn/docs/guide"):
+ page = root / relative / "index.html"
+ page.parent.mkdir(parents=True, exist_ok=True)
+ page.write_text("<html></html>", encoding="utf-8")
+ routes = versioning.generate_version_routes(roots, manifest)
+ self.assertEqual(routes["versions"], ["latest", "2.0", "1.9"])
+ self.assertEqual(
+ list(routes["pages"]["en:guide"]),
+ ["latest", "2.0", "1.9"],
+ )
+ versioning.validate_version_routes(routes, manifest)
+
def test_write_error_documents_keeps_localized_404_status_targets(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
output = Path(temp_name)
@@ -583,6 +1041,51 @@ class VersionUrlTest(unittest.TestCase):
self.assertNotIn(old, rendered)
self.assertEqual(rendered.count(new), expected_count)
+ def test_historical_pages_are_noindex_and_aliases_are_locale_aware(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_name:
+ output = Path(temp_name)
+ for index, language_prefix in enumerate(("", "cn/")):
+ target = (
+ output
+ / language_prefix
+ / "docs/quickstart/hugegraph/hugegraph-server/index.html"
+ )
+ target.parent.mkdir(parents=True)
+ target.write_text(
+ '<meta name="robots" content="index, follow">'
+ if index == 0
+ else '<meta name="robots" content="noindex, nofollow">',
+ encoding="utf-8",
+ )
+ error_page = output / "404.html"
+ error_page.write_text(
+ '<meta name="robots" content="noindex,nofollow">', encoding="utf-8"
+ )
+ self.assertEqual(versioning.mark_historical_pages_noindex(output), 2)
+ self.assertIn(
+ "noindex,nofollow", error_page.read_text(encoding="utf-8")
+ )
+ self.assertEqual(
+ versioning.write_historical_route_aliases(
+ output, ORIGIN, "versions/1.0"
+ ),
+ 2,
+ )
+ for language_prefix in ("", "cn/"):
+ alias = (
+ output
+ / language_prefix
+ / "docs/quickstart/hugegraph-server/index.html"
+ )
+ body = alias.read_text(encoding="utf-8")
+ self.assertIn('content="noindex,follow"', body)
+ self.assertIn(
+ f"{ORIGIN}versions/1.0/{language_prefix}"
+ "docs/quickstart/hugegraph/hugegraph-server/",
+ body,
+ )
+ self.assertFalse((output / "cn/cn").exists())
+
def test_exact_legacy_content_fixes_fail_closed_on_count_drift(self) -> None:
language, relative, old, _, _ = versioning.LEGACY_EXACT_CONTENT_FIXES["1.5"][0]
for source in ("no expected anchor", f"{old}\n{old}"):
@@ -754,6 +1257,70 @@ class VersionUrlTest(unittest.TestCase):
allowed_paths=ALLOWED_PATHS,
)
+ def test_latest_staging_scope_preserves_production_history_selector(self) -> None:
+ manifest = versioning.load_manifest(versioning.ROOT / "versions.json")
+ latest = manifest["versions"][0]
+ with tempfile.TemporaryDirectory() as temp_name:
+ output = Path(temp_name)
+ history_url = f"{ORIGIN}versions/1.7/docs/"
+ page = output / "index.html"
+ page.write_text(
+ f'<a href="{ORIGIN}docs/">latest</a>'
+ f'<a href="{history_url}">1.7</a>',
+ encoding="utf-8",
+ )
+ llms = output / "llms-full.txt"
+ llms.write_text(
+ f"# Corpus\n\n- [Latest]({ORIGIN}docs/)\n"
+ f"- [1.7]({history_url})\n",
+ encoding="utf-8",
... 6106 lines suppressed ...