diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index ecc3c4664..ff1172e3e 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -4,230 +4,449 @@ on: pull_request: push: branches: [master] + 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' && '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 + test "$GITHUB_REF" = refs/heads/master + publish_branch="asf-site" + 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..39b66d839 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. @@ -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 预览网站。 @@ -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..f5e51ba45 --- /dev/null +++ b/assets/js/hugegraph-shell.js @@ -0,0 +1,351 @@ +/** + * 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)'); + + function sync() { + var collapsed = + html.getAttribute('data-td-shell-sidebar') === 'collapsed'; + var drawerOpen = + html.getAttribute('data-td-shell-drawer') === 'open'; + var isolated = desktop.matches ? collapsed : !drawerOpen; + 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'], + }); + desktop.addEventListener('change', sync); + documentObject + .querySelectorAll('[data-td-shell-sidebar-toggle], [data-td-shell-drawer-close]') + .forEach(function (button) { + button.addEventListener('click', function () { + global.queueMicrotask(sync); + }); + }); + sync(); + } + + 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); + 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..e964ec246 --- /dev/null +++ b/assets/js/kapa-adapter.js @@ -0,0 +1,384 @@ +/** + * 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 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 activate(query, submit, trigger) { + query = trimmedQuery(query); + lastTrigger = trigger || documentObject.activeElement; + 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 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.labels.description + + (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..14bfd7e77 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,131 @@ $hg-navbar-purple: #532fc9; display: none; } +.td-nav-util.hg-sidebar-restore { + display: none; +} + +@media (min-width: 768px) { + [data-td-shell-sidebar='collapsed'] { + #td-shell-sidebar { + visibility: hidden; + pointer-events: none; + } + + // OINK v1.0 exposes a left-edge hover overlay after collapse. HugeGraph's + // explicit restore control replaces that hidden target entirely. + #td-shell-sidebar.td-shell-sidebar--overlay + .td-shell-sidebar__panel { + visibility: hidden; + transform: translateX(-100%); + pointer-events: none; + } + + .td-nav-util.hg-sidebar-restore:not([hidden]) { + display: inline-flex; + } + } +} + +.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; 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/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/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/contribution.md b/contribution.md index 864c4a675..d9a5cad32 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 . The local preview includes the language-aware search index so search behavior can be checked before publication. @@ -47,14 +47,20 @@ Open . 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 owns Hugo's configuration, environment, strict-warning, cleanup, +and minification flags and rejects attempts to override them. Use `--baseURL` +or `HG_DOC_SITE_ORIGIN` (not both) when changing the rendered origin; 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 @@ -71,4 +77,3 @@ OINK is a module dependency. Do not copy or edit generated module-cache files. S ## 中文说明 提交前请同时检查中英文页面、公开 URL、搜索结果和语言切换。视觉或导航变更必须提供修改前后的桌面与移动端截图。构建成功只证明模板可以渲染,不能替代真实浏览器、无障碍、打印和 CSP 检查。 - diff --git a/data/version_routes.json b/data/version_routes.json new file mode 100644 index 000000000..72b37a980 --- /dev/null +++ b/data/version_routes.json @@ -0,0 +1,1387 @@ +{ + "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-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/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/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/" + }, + "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-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/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/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/" + } + }, + "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 @@ "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 @@ ("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['\"]?)(?P[^'\"\s)]+)(?P=quote)\s*\)" - r"|@import\s+(?P['\"])(?P[^'\"]+)" - 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 @@ def __init__(self) -> None: 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 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None ) 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 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None 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 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None 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 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None ) 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 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None 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 @@ def handle_data(self, data: str) -> None: 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 inside <{self._media_elements[-1]}>" + ) + else: + self.authored_violations.append( + f"unmatched " + ) 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..dfea2d878 --- /dev/null +++ b/i18n/en.yaml @@ -0,0 +1 @@ +ui_version_fallback: This page is not available in the selected version. You have been redirected to that version's documentation home. 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/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 . -}} + +{{- 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 "%s" $content | safeHTML) -}} diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html new file mode 100644 index 000000000..b8e361764 --- /dev/null +++ b/layouts/_partials/hooks/body-end.html @@ -0,0 +1,54 @@ +{{- $basePath := (urls.Parse .Site.BaseURL).Path | default "/" -}} +{{- $localePrefix := cond (eq .Site.Language.Lang "cn") "cn/" "" -}} +{{- $docsRoot := printf "%s%sdocs/" $basePath $localePrefix -}} +{{- $fallbackMessage := "" -}} +{{- if eq .Site.Language.Lang "cn" -}} + {{- $fallbackMessage = "目标版本没有此页面,已转到该版本的文档首页。" -}} +{{- else -}} + {{- $fallbackMessage = T "ui_version_fallback" -}} +{{- end -}} +{{- $shellConfig := dict + "version" (.Site.Params.version | default "latest") + "locale" .Site.Language.Lang + "docsRoot" $docsRoot + "versionFallbackMessage" $fallbackMessage +-}} + +{{- $shell := resources.Get "js/hugegraph-shell.js" -}} +{{- if hugo.IsProduction }}{{ $shell = $shell | minify | fingerprint }}{{ end }} + + +{{- $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 := cond (eq $lang "cn") + (dict "ask" "询问 AI" "description" "由 Kapa 提供;仅发送你的问题。" "latest" "回答基于 latest 文档" "retry" "重试" "error" "AI 暂时不可用,本地搜索不受影响。") + (dict "ask" "Ask AI" "description" "Powered by Kapa; only your question is sent." "latest" "Answers use the latest documentation" "retry" "Retry" "error" "AI is temporarily unavailable. Local search is unaffected.") + -}} + {{- $clientConfig := dict + "websiteId" $ai.websiteID + "sourceGroupId" $sourceGroup + "locale" (cond (eq $lang "cn") "zh" "en") + "themeColor" $themeColor + "historical" $historical + "labels" $labels + -}} + + +

+ {{ index $labels "description" }}{{ if $historical }} {{ index $labels "latest" }}.{{ end }} +

+
+ {{- $adapter := resources.Get "js/kapa-adapter.js" -}} + {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint }}{{ end }} + +{{- 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 }} + 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 -}} - - - - - {{- 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..0f7d8ab88 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. */ -}} 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 "%s" $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") -}} +
+
+ +
+
+{{- end -}} diff --git a/layouts/_partials/shell/sidebar-panel.html b/layouts/_partials/shell/sidebar-panel.html index ae8692525..a433197ba 100644 --- a/layouts/_partials/shell/sidebar-panel.html +++ b/layouts/_partials/shell/sidebar-panel.html @@ -66,10 +66,10 @@ {{- range . }} {{- $url := strings.TrimSuffix "/" (.url | default "") -}} {{- if $url }} - - {{- partialCached "shell/icon.html" "code-branch" (printf "mobile-version-%s" .version) -}} - {{ .name | default .version }} - + {{- partial "version-link.html" (dict + "page" $ "version" . "active" (eq .version $.Site.Params.version) + "class" "" "iconKey" (printf "mobile-version-%s" .version) + ) -}} {{- end }} {{- 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) -}} + + {{- if ne .icon false -}} + {{- partialCached "shell/icon.html" "code-branch" .iconKey -}} + {{- end -}} + {{ $version.name | default $version.version | markdownify }} + 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..63b0c7a95 --- /dev/null +++ b/layouts/_partials/version-target.html @@ -0,0 +1,63 @@ +{{- $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 := "" -}} + {{- range $candidateID, $candidateRoutes := $pages -}} + {{- if eq (index $candidateRoutes $currentVersion) $relative -}} + {{- $logicalID = $candidateID -}} + {{- end -}} + {{- 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/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" -}} + +{{- $landing := partial "landing/data.html" . -}} +{{- partial "landing/render.html" (dict "page" . "data" $landing) -}} + +{{- 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" -}} + +{{- $landing := partial "landing/data.html" . -}} +{{- partial "landing/render.html" (dict "page" . "data" $landing) -}} + +{{- end }} diff --git a/scripts/hugo.sh b/scripts/hugo.sh new file mode 100755 index 000000000..caaa8adfd --- /dev/null +++ b/scripts/hugo.sh @@ -0,0 +1,162 @@ +#!/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" + +reject_argument() { + printf 'scripts/hugo.sh: argument is owned by the wrapper: %s\n' "$1" >&2 + exit 2 +} + +port= +base_url= +port_set=false +base_url_set=false +expect_value= +for argument in "$@"; do + if [ -n "$expect_value" ]; then + case "$argument" in + -*) printf 'scripts/hugo.sh: missing value for %s\n' "$expect_value" >&2; exit 2 ;; + esac + case "$expect_value" in + baseURL) base_url=$argument; base_url_set=true ;; + port) port=$argument; port_set=true ;; + esac + expect_value= + continue + fi + case "$argument" in + --config|--config=*|-c|-c?*|\ + --configDir|--configDir=*|\ + --environment|--environment=*|-e|-e?*|\ + --cleanDestinationDir|--cleanDestinationDir=*|\ + --gc|--gc=*|\ + --minify|--minify=*|\ + --panicOnWarning|--panicOnWarning=*|\ + --printPathWarnings|--printPathWarnings=*|\ + --printI18nWarnings|--printI18nWarnings=*|\ + --logLevel|--logLevel=*|\ + --appendPort|--appendPort=*) + reject_argument "$argument" + ;; + --baseURL|-b) expect_value=baseURL ;; + --baseURL=*) base_url=${argument#*=}; base_url_set=true ;; + -b=*) base_url=${argument#-b=}; base_url_set=true ;; + -b?*) base_url=${argument#-b}; base_url_set=true ;; + --port|-p) expect_value=port ;; + --port=*) port=${argument#*=}; port_set=true ;; + -p=*) port=${argument#-p=}; port_set=true ;; + -p?*) port=${argument#-p}; port_set=true ;; + esac +done +if [ -n "$expect_value" ]; then + printf 'scripts/hugo.sh: missing value for %s\n' "$expect_value" >&2 + exit 2 +fi +if [ "$base_url_set" = true ] && [ -z "$base_url" ]; then + printf '%s\n' "scripts/hugo.sh: --baseURL cannot be empty" >&2 + exit 2 +fi +if [ "$port_set" = true ] && [ -z "$port" ]; then + printf '%s\n' "scripts/hugo.sh: --port cannot be empty" >&2 + exit 2 +fi +if [ "$base_url_set" = true ] && [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then + printf '%s\n' \ + "scripts/hugo.sh: use either --baseURL or HG_DOC_SITE_ORIGIN, not both" >&2 + exit 2 +fi +if [ "$mode" = "build" ] && [ "$port_set" = true ]; then + printf '%s\n' "scripts/hugo.sh: --port is valid only in server mode" >&2 + exit 2 +fi +if [ -n "$port" ]; then + case "$port" in + *[!0-9]*) printf 'scripts/hugo.sh: invalid port: %s\n' "$port" >&2; exit 2 ;; + esac +fi + +if [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then + site_origin=$HG_DOC_SITE_ORIGIN +elif [ -n "$base_url" ]; then + site_origin=$base_url +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_hugo_wrapper.py b/scripts/test_hugo_wrapper.py new file mode 100644 index 000000000..55fa664a0 --- /dev/null +++ b/scripts/test_hugo_wrapper.py @@ -0,0 +1,219 @@ +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_and_port_keep_generated_and_hugo_origins_aligned(self) -> None: + calls = self.run_wrapper( + "server", + "--baseURL=https://preview.example/docs/", + "-p=1414", + ) + 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[-2:], + ["--baseURL=https://preview.example/docs/", "-p=1414"], + ) + + def test_owned_hugo_arguments_are_rejected_before_any_tool_runs(self) -> None: + cases = ( + ("build", "--config", "other.yaml"), + ("build", "--config=other.yaml"), + ("build", "-c", "other.yaml"), + ("build", "-cother.yaml"), + ("build", "--configDir", "config"), + ("build", "--environment", "development"), + ("build", "--environment=development"), + ("build", "-e", "development"), + ("build", "-edevelopment"), + ("build", "--panicOnWarning=false"), + ("build", "--cleanDestinationDir=false"), + ("build", "--gc=false"), + ("build", "--minify=false"), + ("build", "--printPathWarnings=false"), + ("build", "--printI18nWarnings=false"), + ("build", "--logLevel", "error"), + ("build", "--port", "1414"), + ("server", "--config", "other.yaml"), + ("server", "--appendPort=true"), + ("server", "--baseURL="), + ("server", "--port="), + ("server", "--baseURL", "file:///tmp/site"), + ) + for args in cases: + with self.subTest(args=args): + self.log.unlink(missing_ok=True) + result = self.invoke_wrapper(*args) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), []) + + def test_site_origin_environment_cannot_conflict_with_base_url(self) -> None: + result = self.invoke_wrapper( + "server", + "--baseURL", + "https://preview.example/", + environment_overrides={ + "HG_DOC_SITE_ORIGIN": "https://other.example/" + }, + ) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), []) + + 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 @@ def test_error_document_seo_rejects_indexing_and_url_claims(self) -> None: ], ) + 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 = ( + '' + '' + '' + ) + 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( '' @@ -264,6 +357,30 @@ def test_active_markup_and_event_handlers_inside_content_are_rejected(self) -> N ], ) + def test_duplicate_attributes_are_rejected_case_insensitively(self) -> None: + parser = parse( + '' + '' + "" + ) + self.assertEqual( + parser.authored_violations, + [ + "duplicate src attribute on ", + "duplicate xlink:href attribute on ", + ], + ) + errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + self.assertIn( + "index.html: unsafe content markup: duplicate src attribute on ", + errors, + ) + self.assertIn( + "index.html: unsafe content markup: duplicate xlink:href attribute " + "on ", + errors, + ) + def test_exact_oink_diagram_source_is_allowed_inside_content(self) -> None: parser = parse( '
' + '' + '' + 'allowed contact' + ), + encoding="utf-8", + ) + (root / "boundary.html").write_text( + ( + '
' + '' + '' + "
" + 'ping' + '' + '' + ), + 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( + ( + '
' + '' + '' + "
" + 'ping' + '' + '' + '' + ), + encoding="utf-8", + ) + (root / "external-boundary.html").write_text( + ( + '
' + '' + '' + "
" + 'ping' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ), + 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 href: " + "https://evil.example/payload", + result.stdout, + ) + self.assertIn( + "external active resource is forbidden href: " + "https://evil.example/page", + result.stdout, + ) + self.assertIn( + "external active resource is forbidden xlink:href", + result.stdout, + ) + self.assertIn( + "external active resource is forbidden 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 = ( + '
' + ) + 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
" + "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'' + f'' + f'' + f'' + ), + 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'', + 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( '" + ), + 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("
safe
", encoding="utf-8") + (root / "scss").mkdir() + (root / "img").mkdir() + (root / "img/present.svg").write_text("", 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( + '' + '' + '' + '' + '' + '
hc
' + '' + 'docs' + '' + '' + '' + '' + '' + "" + ) + 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 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( + '' + '' + '' + '' + '' + ) + 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( + '
' + '
' + '' + ) + self.assertIn("authored ' + '' + ) + self.assertIn( + "unexpected authored-content end marker", + spoofed.authored_violations, + ) + self.assertTrue( + VALIDATOR.document_security_errors(spoofed, "index.html", BASE) + ) + + sequential = parse( + '' + "

one

" + '' + '' + "

two

" + '' + ) + 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( + '' + '' + '' + '' + '' + ) + 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 = ( + '' + '' + ) + 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 b1e3e736e..fb826e4af 100644 --- a/scripts/test_versioning.py +++ b/scripts/test_versioning.py @@ -26,6 +26,10 @@ "/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 @@ def test_validate_artifact_rejects_missing_toc_accessible_name(self) -> None: (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( - '', encoding="utf-8" + '' + '' + '', + 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 @@ def test_validate_artifact_rejects_missing_toc_accessible_name(self) -> None: 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 @@ def test_version_urls_preserve_language_and_order(self) -> None: 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 @@ def test_version_urls_preserve_language_and_order(self) -> None: 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 @@ def test_version_urls_preserve_language_and_order(self) -> None: 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'' + f'' + ) + 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('') + + 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( + '' + '' + ) + 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("", 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) @@ -555,6 +1013,51 @@ def test_exact_legacy_content_fixes_cover_every_bound_mapping(self) -> None: 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( + '' + if index == 0 + else '', + encoding="utf-8", + ) + error_page = output / "404.html" + error_page.write_text( + '', 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}"): @@ -709,6 +1212,70 @@ def test_rewrites_production_origin_for_staging(self) -> None: 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'latest' + f'1.7', + 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", + ) + + versioning.scope_version_artifact( + output, + manifest, + latest, + STAGING_ORIGIN, + historical_origin=ORIGIN, + ) + + rendered = page.read_text(encoding="utf-8") + self.assertIn(f'href="{STAGING_ORIGIN}docs/"', rendered) + self.assertIn(f'href="{history_url}"', rendered) + rendered_llms = llms.read_text(encoding="utf-8") + self.assertIn(f"]({STAGING_ORIGIN}docs/)", rendered_llms) + self.assertIn(f"]({history_url})", rendered_llms) + + def test_full_staging_missing_latest_shared_route_uses_staging_origin( + self, + ) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + oldest = next(entry for entry in manifest["versions"] if entry["id"] == "1.0") + with tempfile.TemporaryDirectory() as temp_name: + output = Path(temp_name) + page = output / "docs/index.html" + page.parent.mkdir(parents=True) + page.write_text( + 'Security', + encoding="utf-8", + ) + + versioning.scope_version_artifact( + output, + manifest, + oldest, + STAGING_ORIGIN, + historical_origin=STAGING_ORIGIN, + ) + + self.assertIn( + ( + f'href="{STAGING_ORIGIN}' + 'docs/guides/security/?from=archive#report"' + ), + page.read_text(encoding="utf-8"), + ) + def test_rejects_non_selector_cross_version_url(self) -> None: with self.assertRaises(SystemExit): rewrite("/versions/1.5/docs/config/") @@ -724,6 +1291,7 @@ def test_maps_known_historical_routes(self) -> None: origin=ORIGIN, publish_path="versions/1.5", allowed_paths=ALLOWED_PATHS, + version_id="1.5", ), "https://hugegraph.apache.org/versions/1.5/docs/introduction/readme/", ) @@ -746,14 +1314,61 @@ def test_markdown_rewrite_skips_fenced_code(self) -> None: self.assertIn('example', rendered) self.assertIn('Blog', rendered) + def test_rewrites_each_srcset_and_imagesrcset_candidate(self) -> None: + source = ( + '' + '' + '' + '' + '' + ) + rendered, count = versioning.rewrite_text_urls( + source, + rewrite, + markdown=False, + ) + self.assertEqual(count, 7) + for name in ("one", "two", "three", "four", "five"): + self.assertIn( + f"/versions/1.7/images/{name}.png", + rendered, + ) + self.assertIn(" 1x,", rendered) + self.assertIn(" 800w", rendered) + self.assertIn( + 'xlink:href="/versions/1.7/images/sprite.svg#icon"', + rendered, + ) + self.assertIn( + 'data-td-action-url="/versions/1.7/docs/action/"', + rendered, + ) + + def test_markdown_rewrites_multiline_srcset_outside_fences(self) -> None: + source = ( + "\n' + "```html\n" + "\n' + "```\n" + ) + rendered, count = versioning.rewrite_text_urls( + source, + rewrite, + markdown=True, + ) + self.assertEqual(count, 2) + self.assertIn("/versions/1.7/images/one.png 1x", rendered) + self.assertIn("/versions/1.7/images/two.png 2x", rendered) + self.assertIn('srcset="/images/example.png 1x,', rendered) + self.assertIn("/images/example-2x.png 2x", rendered) + def test_language_fallback_scopes_to_each_artifact_base(self) -> None: - manifest = { - "versions": [ - {"publishPath": ""}, - {"publishPath": "versions/1.7"}, - {"publishPath": "versions/1.5"}, - ] - } + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") relative = "cn/docs/changelog/hugegraph-0.12.0-release-notes/index.html" for publish_path, expected_url in ( ("", "/"), @@ -795,7 +1410,11 @@ def test_language_fallback_scopes_to_each_artifact_base(self) -> None: versioning.scope_version_artifact( output, manifest, - {"publishPath": publish_path}, + next( + entry + for entry in manifest["versions"] + if entry["publishPath"] == publish_path + ), STAGING_ORIGIN, ) @@ -979,6 +1598,37 @@ def test_validate_command_rejects_artifact_sha_drift(self) -> None: with self.assertRaises(SystemExit): versioning.validate_artifact(args) + def test_validate_command_runs_complete_rendered_security_scan(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + artifact = Path(temp_name) / "artifact" + artifact.mkdir() + entry = json.loads( + (versioning.ROOT / "versions.json").read_text(encoding="utf-8") + )["versions"][0] + sha = "a" * 40 + metadata = dict(entry) + metadata.update({"sha": sha, "baseURL": ORIGIN}) + (artifact / ".version.json").write_text( + json.dumps(metadata), encoding="utf-8" + ) + args = argparse.Namespace( + manifest=versioning.ROOT / "versions.json", + version="latest", + sha=sha, + site_origin=ORIGIN, + artifact=artifact, + ) + with ( + mock.patch.object( + versioning, + "validate_output_security", + side_effect=SystemExit("unsafe rendered resource"), + ) as scan, + self.assertRaisesRegex(SystemExit, "unsafe rendered resource"), + ): + versioning.validate_artifact(args) + scan.assert_called_once_with(artifact.resolve(), ORIGIN) + def test_rejects_active_and_ambiguous_url_schemes(self) -> None: for value in ( "javascript:alert(1)", @@ -986,6 +1636,10 @@ def test_rejects_active_and_ambiguous_url_schemes(self) -> None: "file:///etc/passwd", "ftp://example.org/file", "//example.org/path", + "https:///docs/introduction/", + "https:////docs/introduction/", + "http:docs/introduction/", + "/docs/introduction/\t", ): with self.subTest(value=value), self.assertRaises(SystemExit): versioning.require_safe_url_scheme(value, "fixture.html") @@ -993,6 +1647,43 @@ def test_rejects_active_and_ambiguous_url_schemes(self) -> None: self.assertFalse(versioning.require_safe_url_scheme("tel:+1", "x")) self.assertTrue(versioning.require_safe_url_scheme("/docs/", "x")) + def test_document_parser_captures_active_navigation_urls(self) -> None: + parser = versioning.DocumentParser() + parser.feed( + '
' + '' + '' + "
" + '' + '' + '' + '' + '' + 'legacy' + '' + '' + '' + ) + self.assertEqual( + parser.urls, + [ + ("action", "/submit"), + ("formaction", "/button-submit"), + ("formaction", "/input-submit"), + ("data", "/payload"), + ("href", "/map"), + ("href", "/sprite.svg#icon"), + ("xlink:href", "/legacy.svg#icon"), + ("xlink:href", "/legacy-image.svg"), + ("xlink:href", "/legacy-link"), + ("srcset", "/one.png"), + ("srcset", "/two.png"), + ("imagesrcset", "/three.png"), + ("imagesrcset", "/four.png"), + ("data-td-action-url", "/action"), + ], + ) + def test_rejects_resolved_manifest_drift(self) -> None: with tempfile.TemporaryDirectory() as temp_name: manifest = json.loads( @@ -1005,6 +1696,15 @@ def test_rejects_resolved_manifest_drift(self) -> None: path.write_text(json.dumps(manifest), encoding="utf-8") with self.assertRaises(SystemExit): versioning.load_resolved_manifest(path) + manifest = json.loads( + (versioning.ROOT / "versions.json").read_text(encoding="utf-8") + ) + for entry in manifest["versions"]: + entry["sha"] = "a" * 40 + manifest["versions"].pop() + path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaisesRegex(SystemExit, "version order"): + versioning.load_resolved_manifest(path) def test_aggregate_rejects_metadata_sha_before_copy(self) -> None: with tempfile.TemporaryDirectory() as temp_name: @@ -1048,6 +1748,7 @@ def test_aggregate_security_scan_runs_after_metadata_is_written(self) -> None: artifacts=temp / "artifacts", artifact_prefix="", site_origin=ORIGIN, + historical_origin="https://hugegraph.apache.org", output=output, asf_profile="oink", asf_whoami="asf-staging-oink", @@ -1063,6 +1764,9 @@ def assert_complete_aggregate(path: Path, origin: str) -> None: asf_text, ) self.assertTrue((path / "build-metadata/versions.json").is_file()) + self.assertTrue( + (path / "build-metadata/version-routes.json").is_file() + ) with ( mock.patch.object( @@ -1070,10 +1774,25 @@ def assert_complete_aggregate(path: Path, origin: str) -> None: "load_resolved_manifest", return_value={"versions": [entry]}, ), + mock.patch.object( + versioning, + "load_version_routes", + return_value={ + "schemaVersion": 1, + "versions": ["latest"], + "pages": {"en:": {"latest": "docs/"}}, + "equivalents": [], + }, + ), mock.patch.object(versioning, "require_metadata_matches"), - mock.patch.object(versioning, "validate_artifact"), + mock.patch.object( + versioning, "validate_artifact" + ) as validate_artifact, mock.patch.object(versioning, "write_error_documents", return_value=1), mock.patch.object(versioning, "sitemap_locations", return_value=[]), + mock.patch.object( + versioning, "validate_aggregate_version_routes" + ) as validate_routes, mock.patch.object( versioning, "validate_output_security", @@ -1083,6 +1802,12 @@ def assert_complete_aggregate(path: Path, origin: str) -> None: versioning.aggregate(args) security_scan.assert_called_once_with(output.resolve(), ORIGIN) + validate_routes.assert_called_once() + validate_args = validate_artifact.call_args.args[0] + self.assertEqual( + validate_args.historical_origin, + "https://hugegraph.apache.org", + ) def test_output_cleanup_is_limited_to_temporary_descendants(self) -> None: with tempfile.TemporaryDirectory() as temp_name: @@ -1101,6 +1826,790 @@ def test_output_cleanup_is_limited_to_temporary_descendants(self) -> None: versioning.prepare_output_directory(symlink, "fixture") with self.assertRaises(SystemExit): versioning.prepare_output_directory(versioning.ROOT, "fixture") + with self.assertRaises(SystemExit): + versioning.prepare_output_directory(versioning.ROOT.parent, "fixture") + checkout_child = versioning.ROOT / ".test-output-must-not-be-deleted" + checkout_child.mkdir(exist_ok=True) + try: + with self.assertRaises(SystemExit): + versioning.prepare_output_directory(checkout_child, "fixture") + self.assertTrue(checkout_child.is_dir()) + finally: + checkout_child.rmdir() + + def test_output_cleanup_rejects_sibling_checkout_marker(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + sibling = Path(temp_name) / "sibling-worktree" + sibling.mkdir() + (sibling / ".git").write_text( + "gitdir: /tmp/fixture.git/worktrees/sibling\n", + encoding="utf-8", + ) + with ( + mock.patch.object(versioning.shutil, "rmtree") as remove, + self.assertRaisesRegex(SystemExit, "Git checkout"), + ): + versioning.prepare_output_directory(sibling, "fixture") + remove.assert_not_called() + + def test_output_cleanup_rejects_existing_parent_symlink_before_resolve( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + target = temp / "target" + target.mkdir() + linked_parent = temp / "linked-parent" + linked_parent.symlink_to(target, target_is_directory=True) + output = linked_parent / "output" + output.mkdir() + sentinel = output / "sentinel" + sentinel.write_text("keep", encoding="utf-8") + + with self.assertRaisesRegex(SystemExit, "symbolic link"): + versioning.prepare_output_directory(output, "fixture") + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertTrue(output.is_dir()) + + def test_output_cleanup_rejects_symlinked_runner_temp_before_resolve( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + target = temp / "target" + output = target / "output" + output.mkdir(parents=True) + sentinel = output / "sentinel" + sentinel.write_text("keep", encoding="utf-8") + runner_temp = temp / "runner-temp" + runner_temp.symlink_to(target, target_is_directory=True) + + with ( + mock.patch.dict( + versioning.os.environ, + {"RUNNER_TEMP": str(runner_temp)}, + ), + self.assertRaisesRegex(SystemExit, "symbolic link"), + ): + versioning.prepare_output_directory( + runner_temp / "output", + "fixture", + ) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertTrue(output.is_dir()) + + def test_output_cleanup_rejects_symlink_above_runner_temp_before_resolve( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + target = temp / "target" + runner_temp_target = target / "runner-temp" + output = runner_temp_target / "output" + output.mkdir(parents=True) + sentinel = output / "sentinel" + sentinel.write_text("keep", encoding="utf-8") + linked_parent = temp / "linked-parent" + linked_parent.symlink_to(target, target_is_directory=True) + runner_temp = linked_parent / "runner-temp" + + with ( + mock.patch.dict( + versioning.os.environ, + {"RUNNER_TEMP": str(runner_temp)}, + ), + self.assertRaisesRegex(SystemExit, "symbolic link"), + ): + versioning.prepare_output_directory( + runner_temp / "output", + "fixture", + ) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertTrue(output.is_dir()) + + def test_output_cleanup_accepts_real_runner_temp_below_tmp_alias( + self, + ) -> None: + with tempfile.TemporaryDirectory(dir="/tmp") as temp_name: + runner_temp = Path(temp_name) / "runner-temp" + output = runner_temp / "output" + output.mkdir(parents=True) + (output / "stale").write_text("remove", encoding="utf-8") + + with mock.patch.dict( + versioning.os.environ, + {"RUNNER_TEMP": str(runner_temp)}, + ): + self.assertEqual( + versioning.prepare_output_directory(output, "fixture"), + output.resolve(), + ) + + self.assertFalse(output.exists()) + + def test_output_cleanup_rejects_registered_sibling_worktree(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + sibling = Path(temp_name) / "registered-sibling" + sibling.mkdir() + with ( + mock.patch.object( + versioning, + "registered_worktree_roots", + return_value=(versioning.ROOT.resolve(), sibling.resolve()), + ), + mock.patch.object(versioning.shutil, "rmtree") as remove, + self.assertRaisesRegex(SystemExit, "Git checkout"), + ): + versioning.prepare_output_directory(sibling, "fixture") + remove.assert_not_called() + + def test_output_cleanup_fails_closed_when_worktrees_cannot_be_enumerated( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_name: + output = Path(temp_name) / "output" + output.mkdir() + with ( + mock.patch.object( + versioning, + "registered_worktree_roots", + side_effect=SystemExit("cannot enumerate protected Git worktrees"), + ), + mock.patch.object(versioning.shutil, "rmtree") as remove, + self.assertRaisesRegex(SystemExit, "cannot enumerate"), + ): + versioning.prepare_output_directory(output, "fixture") + remove.assert_not_called() + + def test_version_routes_generate_canonical_pages_and_known_equivalents( + self, + ) -> None: + def page(root: Path, relative: str, *, redirect: str | None = None) -> None: + target = root / relative / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + refresh = ( + f'' + if redirect + else "" + ) + target.write_text(f"{refresh}", encoding="utf-8") + + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + roots = {} + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + version_ids = [entry["id"] for entry in manifest["versions"]] + for version in version_ids: + roots[version] = temp / version + page(roots[version], "docs") + page(roots[version], "cn/docs") + spelling = ( + "api-performance" if version == "latest" else "api-preformance" + ) + page(roots[version], f"docs/performance/{spelling}") + page(roots["latest"], "docs/introduction") + page(roots["latest"], "cn/docs/introduction") + page( + roots["latest"], + "docs/introduction/readme", + redirect="/docs/introduction/", + ) + page( + roots["latest"], + "cn/docs/introduction/readme", + redirect="/cn/docs/introduction/", + ) + for version in ("1.7", "1.5", "1.3", "1.0"): + page(roots[version], "docs/introduction/readme") + page(roots[version], "cn/docs/introduction/readme") + + routes = versioning.generate_version_routes(roots, manifest) + + self.assertEqual(routes["versions"], version_ids) + self.assertEqual( + routes["equivalents"], + [ + ["cn:introduction", "cn:introduction/readme"], + ["en:introduction", "en:introduction/readme"], + ], + ) + self.assertEqual( + routes["pages"]["en:performance/api-performance"]["latest"], + "docs/performance/api-performance/", + ) + self.assertEqual( + routes["pages"]["en:performance/api-performance"]["1.7"], + "docs/performance/api-preformance/", + ) + self.assertEqual( + routes["pages"]["en:introduction/readme"]["1.5"], + "docs/introduction/readme/", + ) + self.assertIsNone(routes["pages"]["en:introduction"]["1.5"]) + self.assertEqual( + routes["pages"]["en:introduction/readme"]["1.7"], + "docs/introduction/readme/", + ) + self.assertEqual( + routes["pages"]["en:introduction/readme"]["1.3"], + "docs/introduction/readme/", + ) + versioning.validate_version_routes(routes, manifest) + + def test_version_switch_resolves_unique_equivalent_logical_page(self) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + routes = json.loads( + (versioning.ROOT / "data/version_routes.json").read_text( + encoding="utf-8" + ) + ) + for language, prefix in (("en", ""), ("cn", "cn/")): + with self.subTest(language=language, direction="latest-to-history"): + options = versioning.version_switch_options( + manifest, + routes, + "latest", + f"{prefix}docs/introduction/", + ORIGIN, + language=language, + ) + for version in ("1.5", "1.3", "1.0"): + option = next(item for item in options if item["id"] == version) + self.assertEqual( + option["url"], + ( + f"{ORIGIN}versions/{version}/{prefix}" + "docs/introduction/readme/" + ), + ) + self.assertTrue(option["equivalent"]) + self.assertFalse(option["fallback"]) + + with self.subTest(language=language, direction="history-to-latest"): + options = versioning.version_switch_options( + manifest, + routes, + "1.7", + f"{prefix}docs/introduction/readme/", + ORIGIN, + language=language, + ) + latest = next(item for item in options if item["id"] == "latest") + self.assertEqual( + latest["url"], f"{ORIGIN}{prefix}docs/introduction/" + ) + self.assertTrue(latest["equivalent"]) + self.assertFalse(latest["fallback"]) + + with self.subTest(language=language, direction="direct-wins"): + options = versioning.version_switch_options( + manifest, + routes, + "1.7", + f"{prefix}docs/introduction/", + ORIGIN, + language=language, + ) + latest = next(item for item in options if item["id"] == "latest") + self.assertEqual( + latest["url"], f"{ORIGIN}{prefix}docs/introduction/" + ) + self.assertTrue(latest["equivalent"]) + self.assertFalse(latest["fallback"]) + + def test_version_route_equivalents_reject_unsafe_or_ambiguous_groups( + self, + ) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + versions = list(versioning.manifest_version_ids(manifest)) + targets = {version: None for version in versions} + fixtures = ( + [ + ["en:introduction", "cn:introduction"], + ], + [ + ["en:introduction", "en:introduction/readme"], + ["en:introduction/readme", "en:config"], + ], + [ + [ + "en:introduction", + "en:introduction/readme", + "en:config", + ], + ], + ) + pages = { + "en:introduction": dict(targets, latest="docs/introduction/"), + "en:introduction/readme": dict( + targets, **{"1.7": "docs/introduction/readme/"} + ), + "en:config": dict(targets, **{"1.7": "docs/config/"}), + "cn:introduction": dict(targets, latest="cn/docs/introduction/"), + } + for equivalents in fixtures: + with self.subTest(equivalents=equivalents), self.assertRaises(SystemExit): + versioning.validate_version_routes( + { + "schemaVersion": 1, + "versions": versions, + "pages": pages, + "equivalents": equivalents, + }, + manifest, + ) + + def test_version_routes_reject_alias_target_missing_from_its_artifact( + self, + ) -> None: + def page(root: Path, relative: str, *, redirect: str | None = None) -> None: + target = root / relative / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + refresh = ( + f'' + if redirect + else "" + ) + target.write_text( + f"{refresh}", encoding="utf-8" + ) + + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + roots = {} + for entry in manifest["versions"]: + version = entry["id"] + roots[version] = temp / version + page(roots[version], "docs") + page(roots[version], "cn/docs") + page( + roots["latest"], + "docs/introduction", + redirect="/docs/renamed/", + ) + page(roots["1.5"], "docs/introduction") + page(roots["1.5"], "docs/renamed") + + with self.assertRaisesRegex( + SystemExit, "alias target is missing" + ): + versioning.generate_version_routes(roots, manifest) + + def test_artifact_alias_targets_are_local_scoped_and_present(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + root = Path(temp_name) + target = root / "docs/current/index.html" + target.parent.mkdir(parents=True) + target.write_text("current", encoding="utf-8") + latest = versioning.load_manifest( + versioning.ROOT / "versions.json" + )["versions"][0] + + for value in ( + "/docs/current/", + f"{ORIGIN}docs/current/", + ): + self.assertEqual( + versioning.validate_artifact_alias_target( + value, + root, + latest, + ORIGIN, + "docs/old/index.html", + ), + "/docs/current/", + ) + + invalid = ( + "https://evil.example/docs/current/", + "https:///docs/current/", + "http:///docs/current/", + "mailto:dev@example.com", + "tel:+123", + "docs/current/", + "/docs/missing/", + "/docs/current/?next=1", + "/docs/current/#next", + "/docs/%63urrent/", + "/docs/./current/", + "/docs//current/", + "/docs/current/\t", + ) + for value in invalid: + with self.subTest(value=value), self.assertRaises(SystemExit): + versioning.validate_artifact_alias_target( + value, + root, + latest, + ORIGIN, + "docs/old/index.html", + ) + + archived = next( + entry + for entry in versioning.load_manifest( + versioning.ROOT / "versions.json" + )["versions"] + if entry["id"] == "1.7" + ) + archived_base = f"{ORIGIN}versions/1.7/" + self.assertEqual( + versioning.validate_artifact_alias_target( + f"{archived_base}docs/current/", + root, + archived, + archived_base, + "docs/old/index.html", + ), + "/versions/1.7/docs/current/", + ) + with self.assertRaisesRegex(SystemExit, "escapes version"): + versioning.validate_artifact_alias_target( + "/docs/current/", + root, + archived, + archived_base, + "docs/old/index.html", + ) + + def test_historical_home_aliases_allow_only_exact_shared_roots(self) -> None: + for origin in (ORIGIN, STAGING_ORIGIN): + with self.subTest(origin=origin): + versioning.validate_historical_home_alias_target( + origin, + "index.html", + origin, + ) + versioning.validate_historical_home_alias_target( + f"{origin}cn/", + "cn/index.html", + origin, + ) + + invalid = ( + ("https://evil.example/", "index.html"), + (f"{ORIGIN}cn/", "index.html"), + (ORIGIN, "cn/index.html"), + (f"{ORIGIN}docs/", "index.html"), + ("mailto:dev@example.com", "index.html"), + ) + for value, relative in invalid: + with self.subTest(value=value, relative=relative), self.assertRaises( + SystemExit + ): + versioning.validate_historical_home_alias_target( + value, + relative, + ORIGIN, + ) + + def test_version_routes_reject_external_and_protocol_aliases(self) -> None: + def page(root: Path, relative: str, *, redirect: str | None = None) -> None: + target = root / relative / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + refresh = ( + f'' + if redirect + else "" + ) + target.write_text( + f"{refresh}", encoding="utf-8" + ) + + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + for redirect in ( + "https://evil.example/docs/current/", + "mailto:dev@example.com", + "tel:+123", + ): + with self.subTest(redirect=redirect), tempfile.TemporaryDirectory() as name: + temp = Path(name) + roots = {} + for entry in manifest["versions"]: + roots[entry["id"]] = temp / entry["id"] + page(roots[entry["id"]], "docs") + page(roots[entry["id"]], "cn/docs") + page(roots["latest"], "docs/old", redirect=redirect) + page(roots["latest"], "docs/current") + with self.assertRaises(SystemExit): + versioning.generate_version_routes(roots, manifest) + + def test_version_routes_accept_alias_chain_to_same_version_canonical( + self, + ) -> None: + def page(root: Path, relative: str, *, redirect: str | None = None) -> None: + target = root / relative / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + refresh = ( + f'' + if redirect + else "" + ) + target.write_text( + f"{refresh}", encoding="utf-8" + ) + + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + with tempfile.TemporaryDirectory() as temp_name: + temp = Path(temp_name) + roots = {} + for entry in manifest["versions"]: + version = entry["id"] + roots[version] = temp / version + page(roots[version], "docs") + page(roots[version], "cn/docs") + page(roots["latest"], "docs/old", redirect="/docs/middle/") + page(roots["latest"], "docs/middle", redirect="/docs/current/") + page(roots["latest"], "docs/current") + page(roots["1.7"], "docs/old") + + routes = versioning.generate_version_routes(roots, manifest) + + self.assertEqual( + routes["equivalents"], + [["en:current", "en:old"]], + ) + + def test_version_route_equivalents_reject_mixed_types_cleanly(self) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + versions = list(versioning.manifest_version_ids(manifest)) + routes = { + "schemaVersion": 1, + "versions": versions, + "pages": { + "en:": { + version: "docs/" for version in versions + }, + }, + "equivalents": [["en:", 7]], + } + with self.assertRaisesRegex( + SystemExit, "invalid version route-map equivalent group" + ): + versioning.validate_version_routes(routes, manifest) + + def test_version_switch_options_use_equivalent_page_or_explicit_fallback( + self, + ) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + version_ids = versioning.manifest_version_ids(manifest) + routes = { + "schemaVersion": 1, + "versions": list(version_ids), + "pages": { + "en:config": { + "latest": "docs/config/", + "1.7": "docs/config/", + "1.5": None, + "1.3": "docs/config/", + "1.0": None, + } + }, + "equivalents": [], + } + options = versioning.version_switch_options( + manifest, + routes, + "latest", + "docs/config/", + STAGING_ORIGIN, + historical_origin=ORIGIN, + ) + self.assertEqual( + set(options[0]), + { + "id", + "title", + "url", + "active", + "available", + "disabledReason", + "equivalent", + "fallback", + }, + ) + self.assertEqual(options[0]["url"], f"{STAGING_ORIGIN}docs/config/") + self.assertTrue(options[0]["equivalent"]) + self.assertFalse(options[0]["fallback"]) + self.assertEqual(options[1]["url"], f"{ORIGIN}versions/1.7/docs/config/") + self.assertEqual( + options[2]["url"], + f"{ORIGIN}versions/1.5/docs/#hg-version-fallback", + ) + self.assertFalse(options[2]["equivalent"]) + self.assertTrue(options[2]["fallback"]) + + non_docs = versioning.version_switch_options( + manifest, + routes, + "latest", + None, + STAGING_ORIGIN, + historical_origin=ORIGIN, + language="cn", + ) + self.assertEqual(non_docs[1]["url"], f"{ORIGIN}versions/1.7/cn/docs/") + self.assertFalse(non_docs[1]["equivalent"]) + self.assertFalse(non_docs[1]["fallback"]) + + def test_scope_preserves_hugo_version_options_instead_of_rebuilding_them( + self, + ) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + latest = manifest["versions"][0] + authored_options = [ + { + "id": entry["id"], + "title": entry["name"], + "url": ( + f"{ORIGIN}{entry['publishPath'].rstrip('/')}/docs/" + if entry["publishPath"] + else f"{ORIGIN}docs/" + ), + "active": entry["id"] == latest["id"], + "available": True, + "disabledReason": "", + "equivalent": True, + "fallback": False, + } + for entry in manifest["versions"] + ] + action_data = { + "actions": [{"id": "switch_version", "options": authored_options}] + } + with tempfile.TemporaryDirectory() as temp_name: + output = Path(temp_name) + page = output / "docs/index.html" + page.parent.mkdir(parents=True) + page.write_text( + '' + '", + encoding="utf-8", + ) + with ( + mock.patch.object( + versioning, + "version_switch_options", + side_effect=AssertionError( + "scope must not rebuild Hugo-authored version options" + ), + ), + mock.patch.object( + versioning, + "load_version_routes", + return_value=json.loads( + (versioning.ROOT / "data/version_routes.json").read_text( + encoding="utf-8" + ) + ), + ), + ): + versioning.scope_version_artifact( + output, + manifest, + latest, + ORIGIN, + historical_origin=ORIGIN, + ) + + rendered = page.read_text(encoding="utf-8") + scoped = json.loads( + versioning.ACTION_MANIFEST_RE.search(rendered).group("body") + ) + self.assertEqual(scoped["actions"][0]["options"], authored_options) + + def test_version_validator_compares_palette_with_every_native_anchor( + self, + ) -> None: + option = { + "id": "1.7", + "title": "1.7", + "url": f"{ORIGIN}versions/1.7/docs/config/", + "active": False, + "available": True, + "disabledReason": "", + "equivalent": True, + "fallback": False, + } + parser = versioning.DocumentParser() + parser.feed( + '1.7' + '1.7' + ) + + with self.assertRaisesRegex(SystemExit, "native version link mismatch"): + versioning.require_version_switch_matches_native( + parser, [option], "docs/config/index.html" + ) + + active = dict(option, active=True) + parser.version_links[0]["aria-current"] = "page" + versioning.require_version_switch_matches_native( + parser, [active], "docs/config/index.html" + ) + + parser.version_links[1]["href"] = f"{ORIGIN}docs/" + with self.assertRaisesRegex(SystemExit, "native version link mismatch"): + versioning.require_version_switch_matches_native( + parser, [active], "docs/config/index.html" + ) + + def test_aggregate_version_routes_reject_missing_targets_and_false_nulls( + self, + ) -> None: + manifest = versioning.load_manifest(versioning.ROOT / "versions.json") + version_ids = versioning.manifest_version_ids(manifest) + routes = { + "schemaVersion": 1, + "versions": list(version_ids), + "pages": { + "en:config": { + "latest": "docs/config/", + "1.7": None, + "1.5": None, + "1.3": None, + "1.0": None, + } + }, + "equivalents": [], + } + with tempfile.TemporaryDirectory() as temp_name: + output = Path(temp_name) + (output / "cn/docs").mkdir(parents=True) + target = output / "docs/config/index.html" + target.parent.mkdir(parents=True) + target.write_text("", encoding="utf-8") + versioning.validate_aggregate_version_routes( + output, routes, {"latest"}, manifest + ) + target.unlink() + with self.assertRaisesRegex(SystemExit, "route-map target is missing"): + versioning.validate_aggregate_version_routes( + output, routes, {"latest"}, manifest + ) + + target.write_text("", encoding="utf-8") + hidden = output / "versions/1.7/docs/config/index.html" + hidden.parent.mkdir(parents=True) + hidden.write_text("", encoding="utf-8") + (output / "versions/1.7/cn/docs").mkdir(parents=True) + with self.assertRaisesRegex(SystemExit, "route-map null target exists"): + versioning.validate_aggregate_version_routes( + output, + routes, + {"latest", "1.7"}, + manifest, + ) if __name__ == "__main__": diff --git a/scripts/versioning.py b/scripts/versioning.py index 57c3d99a5..438ef4e5d 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -37,6 +37,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] URL_CONTRACT = ROOT / "dist/url-contract.json" +VERSION_ROUTES = ROOT / "data/version_routes.json" CANONICAL_ORIGIN = "https://hugegraph.apache.org/" SHELL_FILES = ("go.mod", "go.sum", "hugo.yaml") SHELL_DIRS = ("assets", "data", "i18n", "layouts") @@ -44,7 +45,14 @@ "content/en/docs/_nav", "content/cn/docs/_nav", ) -SHELL_STATIC_FILES = ("favicon.svg",) +SHELL_STATIC_FILES = ( + "favicon.svg", + "img/social/hugegraph-default.png", +) +SHELL_STATIC_DIRS = ( + "img/bootstrap-controls", + "img/home", +) SHELL_CONTENT = ( "content/en/_index.md", "content/cn/_index.md", @@ -60,32 +68,50 @@ ) SHA_RE = re.compile(r"^[0-9a-f]{40}$") REPOSITORY_URL = "https://github.com/apache/hugegraph-doc.git" -VERSION_REFS = { - "latest": "master", - "1.7": "release-1.7.0", - "1.5": "release-1.5.0", -} KNOWN_HISTORICAL_ROUTES = { "/docs/quickstart/hugegraph-loader": "/docs/quickstart/toolchain/hugegraph-loader/", "/cn/docs/quickstart/hugegraph-loader": "/cn/docs/quickstart/toolchain/hugegraph-loader/", } -KNOWN_HISTORICAL_ROUTES_BY_PUBLISH_PATH = { - "versions/1.5": { +KNOWN_HISTORICAL_ROUTES_BY_VERSION = { + "1.5": { "/docs/introduction": "/docs/introduction/readme/", "/cn/docs/introduction": "/cn/docs/introduction/readme/", }, } +LATEST_SHARED_DOC_ROUTES = { + "/docs/guides/security/", + "/cn/docs/guides/security/", +} URL_ATTRIBUTE_RE = re.compile( - r"(?P[\s<](?:href|src|action|poster|data-td-index-src|data-td-url|data-td-image-zoom)=)" + r"(?P[\s<](?:href|xlink:href|src|action|formaction|data|poster|data-td-index-src|data-td-url|data-td-action-url|data-td-image-zoom)=)" r"(?P[\"']?)(?P[^\s\"'<>`]+)(?P=quote)", re.IGNORECASE, ) +URL_LIST_ATTRIBUTE_RE = re.compile( + r"(?P[\s<](?:srcset|imagesrcset)=)" + r"(?P[\"'])(?P.*?)(?P=quote)", + re.IGNORECASE | re.DOTALL, +) +URL_LIST_UNQUOTED_ATTRIBUTE_RE = re.compile( + r"(?P[\s<](?:srcset|imagesrcset)=)" + r"(?P[^\s\"'<>`]+)", + re.IGNORECASE, +) ACTION_MANIFEST_RE = re.compile( r"(?P]*\bid=[\"']?td-action-manifest[\"']?[^>]*>)" r"(?P.*?)" r"(?P)", re.IGNORECASE | re.DOTALL, ) +HREFLANG_LINK_RE = re.compile( + r"]*\brel=[\"']?alternate(?:[\"'\s>]|$))" + r"(?=[^>]*\bhreflang=)[^>]*>", + re.IGNORECASE, +) +ROBOTS_META_RE = re.compile( + r"]*\bname=[\"']?robots(?:[\"'\s>]|$))[^>]*>", + re.IGNORECASE, +) MARKDOWN_DESTINATION_RE = re.compile( r"(?P\]\(\s*(?:https?://[^\s)>]+|/[^\s)>]+))(?P>?[^)]*\))" ) @@ -121,9 +147,73 @@ "scopedLinks": 10, "treeSha256": "70b2a46f047b3c88a6b1b937eb79676a7f68437485b3248e5f84b9c621d5ad06", }, + "1.3": { + "groups": 5, + "pages": 68, + "removed": 22, + "scopedLinks": 10, + "treeSha256": "78700dfe484d5177f00c9fe657ae12f3ceecca9bbb592cc8626b782a7b9be61a", + }, + "1.0": { + "groups": 5, + "pages": 59, + "removed": 21, + "scopedLinks": 10, + "treeSha256": "52f0c3b5bef6e9db39a37bbd52b7993128951f592d88a835ec7e3c107edec399", + }, +} +LEGACY_IA_ROUTE_MAP = { + "introduction/README.md": "introduction/readme.md", + "quickstart/hugegraph-server.md": "quickstart/hugegraph/hugegraph-server.md", + "quickstart/hugegraph-hubble.md": "quickstart/toolchain/hugegraph-hubble.md", + "quickstart/hugegraph-loader.md": "quickstart/toolchain/hugegraph-loader.md", + "quickstart/hugegraph-tools.md": "quickstart/toolchain/hugegraph-tools.md", + "quickstart/hugegraph-computer.md": "quickstart/computing/hugegraph-computer.md", + "quickstart/hugegraph-client.md": "quickstart/client/hugegraph-client.md", +} +LEGACY_IA_SECTIONS = { + "quickstart/hugegraph": ("HugeGraph", "HugeGraph"), + "quickstart/toolchain": ("Toolchain", "Toolchain"), + "quickstart/computing": ("Graph computing", "图计算"), + "quickstart/client": ("Clients", "客户端"), } +def srcset_urls(value: str) -> list[str]: + """Return each URL candidate without treating data-URL commas as separators.""" + urls: list[str] = [] + position = 0 + while position < len(value): + while position < len(value) and ( + value[position].isspace() or value[position] == "," + ): + position += 1 + if position == len(value): + break + + start = position + is_data_url = value[start:].lower().startswith("data:") + while position < len(value) and not value[position].isspace(): + if value[position] == "," and not is_data_url: + break + position += 1 + token = value[start:position] + trailing_commas = len(token) - len(token.rstrip(",")) + token = token.rstrip(",") + if token: + urls.append(token) + if position < len(value) and value[position] == ",": + position += 1 + continue + if trailing_commas: + continue + while position < len(value) and value[position] != ",": + position += 1 + if position < len(value): + position += 1 + return urls + + class DocumentParser(html.parser.HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) @@ -132,6 +222,7 @@ def __init__(self) -> None: self.hreflang: list[tuple[str, str]] = [] self.meta: list[dict[str, str]] = [] self.toc_nav_labels: list[list[str]] = [] + self.version_links: list[dict[str, str]] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = {key.lower(): value or "" for key, value in attrs} @@ -143,15 +234,27 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None ) for attribute in ( "href", + "xlink:href", "src", "action", "poster", "data-td-index-src", "data-td-url", + "data-td-action-url", "data-td-image-zoom", ): if values.get(attribute): self.urls.append((attribute, values[attribute])) + if tag.lower() in {"button", "input"} and values.get("formaction"): + self.urls.append(("formaction", values["formaction"])) + if tag.lower() == "object" and values.get("data"): + self.urls.append(("data", values["data"])) + for attribute in ("srcset", "imagesrcset"): + if values.get(attribute): + self.urls.extend( + (attribute, value) + for value in srcset_urls(values[attribute]) + ) if tag == "link" and values.get("rel", "").lower() == "canonical": self.canonical.append(values.get("href", "")) if ( @@ -162,6 +265,8 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self.hreflang.append((values["hreflang"], values.get("href", ""))) if tag == "meta": self.meta.append(values) + if tag == "a" and "data-hg-version-id" in values: + self.version_links.append(values) def refresh_target(parser: DocumentParser) -> str | None: @@ -209,15 +314,80 @@ def require_toc_accessible_name(parser: DocumentParser, relative: str) -> None: ) +def require_version_switch_matches_native( + parser: DocumentParser, options: list[dict], relative: str +) -> None: + """Require every native version link to match its Palette option.""" + if not isinstance(options, list): + fail(f"version switch options are invalid in {relative}") + by_id: dict[str, dict] = {} + for option in options: + version_id = option.get("id") if isinstance(option, dict) else None + if ( + not isinstance(version_id, str) + or not version_id + or version_id in by_id + or not isinstance(option.get("url"), str) + or not isinstance(option.get("available"), bool) + or not isinstance(option.get("active"), bool) + or not isinstance(option.get("equivalent"), bool) + or not isinstance(option.get("fallback"), bool) + ): + fail(f"version switch options are invalid in {relative}") + by_id[version_id] = option + + expected_ids = { + version_id + for version_id, option in by_id.items() + if option["available"] + } + observed_ids = { + link.get("data-hg-version-id", "") for link in parser.version_links + } + if observed_ids != expected_ids: + fail( + f"native version link set mismatch in {relative}: " + f"{sorted(observed_ids)!r} != {sorted(expected_ids)!r}" + ) + + for link in parser.version_links: + version_id = link["data-hg-version-id"] + option = by_id[version_id] + observed = { + "url": link.get("href", ""), + "equivalent": link.get("data-hg-version-equivalent") == "true", + "fallback": link.get("data-hg-version-fallback") == "true", + "active": link.get("aria-current") == "page", + } + expected = { + "url": option["url"], + "equivalent": option["equivalent"], + "fallback": option["fallback"], + "active": option["active"], + } + if observed != expected: + fail( + f"native version link mismatch in {relative} for " + f"{version_id}: {observed!r} != {expected!r}" + ) + + def require_safe_url_scheme(value: str, source: str) -> bool: """Reject active or ambiguous schemes; return whether target validation applies.""" if value.startswith("//"): fail(f"protocol-relative URL in {source}: {value}") - scheme = urllib.parse.urlsplit(value).scheme.lower() + if any( + char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value + ): + fail(f"whitespace/control URL in {source}: {value}") + parts = urllib.parse.urlsplit(value) + scheme = parts.scheme.lower() if scheme in {"mailto", "tel"}: return False if scheme not in {"", "http", "https"}: fail(f"forbidden URL scheme in {source}: {value}") + if scheme in {"http", "https"} and not parts.netloc: + fail(f"HTTP(S) URL has no authority in {source}: {value}") return True @@ -225,20 +395,99 @@ def fail(message: str) -> NoReturn: raise SystemExit(message) +def registered_worktree_roots() -> tuple[pathlib.Path, ...]: + """Enumerate every checkout sharing this repository, failing closed.""" + try: + result = subprocess.run( + ["git", "worktree", "list", "--porcelain", "-z"], + cwd=ROOT, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as exc: + fail(f"cannot enumerate protected Git worktrees: {exc}") + if result.returncode != 0: + detail = os.fsdecode(result.stderr).strip() + fail(f"cannot enumerate protected Git worktrees: {detail or result.returncode}") + roots = [] + for field in result.stdout.split(b"\0"): + if not field.startswith(b"worktree "): + continue + raw = os.fsdecode(field.removeprefix(b"worktree ")) + path = pathlib.Path(raw) + if not raw or not path.is_absolute(): + fail(f"invalid Git worktree path: {raw!r}") + roots.append(path.resolve()) + if not roots: + fail("cannot enumerate protected Git worktrees: no checkout paths") + return tuple(roots) + + +def require_output_outside_git_checkouts(output: pathlib.Path, label: str) -> None: + """Reject registered, prunable, and unregistered checkout paths.""" + protected = set(registered_worktree_roots()) + protected.add(ROOT.resolve()) + for checkout in protected: + if ( + output == checkout + or output in checkout.parents + or checkout in output.parents + ): + fail(f"{label} must be outside every Git checkout: {output}") + for candidate in (output, *output.parents): + marker = candidate / ".git" + if marker.exists() or marker.is_symlink(): + fail(f"{label} must be outside every Git checkout: {output}") + + +def require_no_symlinked_output_components( + path: pathlib.Path, + label: str, + controlled_roots: set[pathlib.Path], +) -> pathlib.Path: + """Reject existing symlinks below a trusted temp root before resolution.""" + absolute = pathlib.Path(os.path.abspath(os.fspath(path))) + lexical_roots = { + pathlib.Path(os.path.abspath(os.fspath(root))) for root in controlled_roots + } + lexical_roots.update(root.resolve() for root in controlled_roots) + anchors = [ + root + for root in lexical_roots + if absolute == root or root in absolute.parents + ] + anchor = max(anchors, key=lambda item: len(item.parts)) if anchors else None + candidate = absolute + while candidate != anchor and candidate != candidate.parent: + if candidate.is_symlink(): + fail(f"{label} must not contain a symbolic link: {candidate}") + candidate = candidate.parent + return absolute + + def prepare_output_directory(path: pathlib.Path, label: str) -> pathlib.Path: raw = path.expanduser() - if raw.is_symlink(): - fail(f"{label} must not be a symbolic link: {raw}") - output = raw.resolve() - allowed_roots = { - pathlib.Path(tempfile.gettempdir()).resolve(), - pathlib.Path("/tmp").resolve(), + controlled_roots = { + pathlib.Path(tempfile.gettempdir()), + pathlib.Path("/tmp"), } runner_temp = os.environ.get("RUNNER_TEMP") if runner_temp: - allowed_roots.add(pathlib.Path(runner_temp).resolve()) + runner_temp_root = require_no_symlinked_output_components( + pathlib.Path(runner_temp).expanduser(), + "RUNNER_TEMP", + controlled_roots, + ) + controlled_roots.add(runner_temp_root) + raw_absolute = require_no_symlinked_output_components( + raw, label, controlled_roots + ) + output = raw_absolute.resolve() + allowed_roots = {root.resolve() for root in controlled_roots} if not any(root != output and root in output.parents for root in allowed_roots): fail(f"{label} must be below a controlled temporary directory: {output}") + require_output_outside_git_checkouts(output, label) if output.exists(): if output.is_symlink() or not output.is_dir(): fail(f"{label} is not a removable directory: {output}") @@ -267,35 +516,61 @@ def load_manifest(path: pathlib.Path) -> dict: if not isinstance(versions, list) or not versions: fail("versions manifest must contain a non-empty versions array") ids: set[str] = set() + refs: set[str] = set() paths: set[str] = set() - for entry in versions: + unarchived = [] + for index, entry in enumerate(versions): required = {"id", "name", "ref", "publishPath", "archived", "githubBranch"} if not isinstance(entry, dict) or not required.issubset(entry): fail(f"invalid version entry: {entry!r}") version_id = entry["id"] - expected_ref = VERSION_REFS.get(version_id) - if entry["ref"] != expected_ref: - fail(f"unexpected source ref for {version_id}: {entry['ref']}") - if entry["githubBranch"] != expected_ref: - fail(f"unexpected GitHub branch for {version_id}: {entry['githubBranch']}") - if entry["name"] != version_id: - fail(f"unexpected display name for {version_id}: {entry['name']}") - if entry["archived"] is not (version_id != "latest"): - fail(f"unexpected archive state for {version_id}: {entry['archived']}") + values = { + "id": version_id, + "name": entry["name"], + "ref": entry["ref"], + "githubBranch": entry["githubBranch"], + } + if not all(isinstance(value, str) and value for value in values.values()): + fail(f"version string fields must be non-empty: {entry!r}") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", version_id): + fail(f"unsafe version id: {version_id!r}") + for field in ("ref", "githubBranch"): + value = entry[field] + if ( + not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", value) + or value.endswith("/") + or "//" in value + or ".." in value + ): + fail(f"unsafe version {field}: {value!r}") + if not isinstance(entry["archived"], bool): + fail(f"version archived state must be boolean: {entry!r}") + if not isinstance(entry["publishPath"], str): + fail(f"version publishPath must be a string: {entry!r}") publish_path = entry["publishPath"].strip("/") - if version_id in ids or publish_path in paths: + if ( + entry["publishPath"] != publish_path + or "\\" in publish_path + or "?" in publish_path + or "#" in publish_path + ): + fail(f"unsafe publish path for {version_id}: {entry['publishPath']!r}") + if version_id in ids or entry["ref"] in refs or publish_path in paths: fail(f"duplicate version id or publish path: {version_id}") - if version_id == "latest" and publish_path: - fail("latest must publish at the site root") - if version_id != "latest" and not publish_path.startswith("versions/"): - fail(f"historical version {version_id} must publish below versions/") + if not entry["archived"]: + unarchived.append(version_id) + if index != 0 or publish_path: + fail("the first manifest version must be the unarchived site root") + elif not publish_path.startswith("versions/"): + fail(f"archived version {version_id} must publish below versions/") if ".." in pathlib.PurePosixPath(publish_path).parts: fail(f"unsafe publish path for {version_id}: {publish_path}") entry["publishPath"] = publish_path ids.add(version_id) + refs.add(entry["ref"]) paths.add(publish_path) - if [entry["id"] for entry in versions] != ["latest", "1.7", "1.5"]: - fail("version order must be latest, 1.7, 1.5") + if len(unarchived) != 1: + fail("versions manifest must contain exactly one unarchived version") if data.get("repository") != REPOSITORY_URL: fail(f"versions manifest repository must be {REPOSITORY_URL}") return data @@ -306,6 +581,8 @@ def load_resolved_manifest(path: pathlib.Path) -> dict: expected = load_manifest(ROOT / "versions.json") if resolved.get("repository") != expected.get("repository"): fail("resolved manifest repository does not match versions.json") + if manifest_version_ids(resolved) != manifest_version_ids(expected): + fail("resolved manifest version order does not match versions.json") fields = ("id", "name", "ref", "publishPath", "archived", "githubBranch") for expected_entry, resolved_entry in zip( expected["versions"], resolved["versions"] @@ -344,12 +621,30 @@ def resolve_remote(repository: str, ref: str) -> str: return rows[0][0] +def manifest_version_ids(manifest: dict) -> tuple[str, ...]: + return tuple(entry["id"] for entry in manifest["versions"]) + + +def selected_version_ids(raw: str | None, manifest: dict) -> tuple[str, ...]: + version_ids = manifest_version_ids(manifest) + if raw is None: + return version_ids + selected = tuple(part.strip() for part in raw.split(",") if part.strip()) + if not selected or len(set(selected)) != len(selected): + fail("selected versions must be a non-empty unique comma-separated list") + unknown = set(selected) - set(version_ids) + if unknown: + fail(f"unknown selected versions: {', '.join(sorted(unknown))}") + return tuple(version for version in version_ids if version in selected) + + def prepare(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) + selected = selected_version_ids(args.select, manifest) resolved = [] for entry in manifest["versions"]: item = dict(entry) - if entry["id"] == "latest": + if not entry["archived"]: sha = run(["git", "rev-parse", f"{args.latest_sha}^{{commit}}"]) elif args.local: sha = run( @@ -365,7 +660,7 @@ def prepare(args: argparse.Namespace) -> None: "schemaVersion": 1, "repository": manifest["repository"], "versions": resolved, - "include": resolved, + "include": [item for item in resolved if item["id"] in selected], } rendered = json.dumps( result, ensure_ascii=False, sort_keys=True, separators=(",", ":") @@ -401,6 +696,12 @@ def overlay_shell(assembly: pathlib.Path, *, historical: bool, origin: str) -> N target = assembly / "static" / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) + for relative in SHELL_STATIC_DIRS: + source = ROOT / "static" / relative + target = assembly / "static" / relative + if target.exists(): + shutil.rmtree(target) + shutil.copytree(source, target) client_go_target = assembly / "static/client-go" if historical: if client_go_target.exists(): @@ -485,16 +786,21 @@ def strip_menu_frontmatter(path: pathlib.Path) -> None: if not path.is_file(): return lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - if not lines or lines[0].strip() != "---": + opening_index = next( + (index for index, line in enumerate(lines) if line.strip()), None + ) + if opening_index is None or lines[opening_index].strip() != "---": fail(f"cannot remove menu from non-YAML front matter: {path}") try: closing_index = next( - index for index, line in enumerate(lines[1:], 1) if line.strip() == "---" + index + for index, line in enumerate(lines[opening_index + 1 :], opening_index + 1) + if line.strip() == "---" ) except StopIteration: fail(f"unterminated YAML front matter: {path}") - frontmatter = lines[1:closing_index] - output = [lines[0]] + frontmatter = lines[opening_index + 1 : closing_index] + output = lines[: opening_index + 1] index = 0 while index < len(frontmatter): line = frontmatter[index] @@ -539,6 +845,62 @@ def ensure_frontmatter( return 1 +def add_frontmatter_alias(path: pathlib.Path, alias: str) -> None: + source = path.read_text(encoding="utf-8") + opening = source.find("---") + if opening < 0 or source[:opening].strip(): + fail(f"cannot add route alias without YAML front matter: {path}") + closing = source.find("\n---", opening + 3) + if closing < 0: + fail(f"unterminated YAML front matter: {path}") + frontmatter = source[opening + 3 : closing] + if re.search(r"(?m)^aliases\s*:", frontmatter): + fail(f"historical page already declares aliases: {path}") + source = source[:closing] + f"\naliases:\n - {alias}\n" + source[closing:] + path.write_text(source, encoding="utf-8") + + +def migrate_legacy_information_architecture( + assembly: pathlib.Path, version: str +) -> int: + """Move 1.3/1.0 flat pages into the current five-group route hierarchy.""" + if version not in {"1.3", "1.0"}: + return 0 + changed = 0 + for language in ("en", "cn"): + docs = assembly / "content" / language / "docs" + for section, titles in LEGACY_IA_SECTIONS.items(): + index = docs / section / "_index.md" + if not index.exists(): + index.parent.mkdir(parents=True, exist_ok=True) + title = titles[0] if language == "en" else titles[1] + index.write_text( + f'---\ntitle: "{title}"\nlinkTitle: "{title}"\n---\n', + encoding="utf-8", + ) + changed += 1 + for old_relative, new_relative in LEGACY_IA_ROUTE_MAP.items(): + source = docs / old_relative + if not source.is_file(): + continue + target = docs / new_relative + if target.exists(): + if not source.samefile(target): + fail(f"historical route migration target already exists: {target}") + intermediate = source.with_name(source.name + ".route-migration-tmp") + source.rename(intermediate) + source = intermediate + target.parent.mkdir(parents=True, exist_ok=True) + source.rename(target) + changed += 1 + summary = docs / "SUMMARY.md" + text = summary.read_text(encoding="utf-8") + for old_relative, new_relative in LEGACY_IA_ROUTE_MAP.items(): + text = text.replace(old_relative, new_relative) + summary.write_text(text, encoding="utf-8") + return changed + + def ensure_search_metadata( path: pathlib.Path, *, keywords: tuple[str, ...], boost: float ) -> int: @@ -758,9 +1120,9 @@ def index_node(node: dict, ancestors: list[str]) -> None: LEGACY_WECHAT_IMAGE_RE = re.compile( - r']*\bsrc="(?:https://github\.com/apache/hugegraph-doc/' + r']*\bsrc="(?:https://github\.com/apache/(?:incubator-)?hugegraph-doc/' r"blob/master/assets/images/wechat\.png\?raw=true|https://raw\.githubusercontent\.com/" - r'apache/hugegraph-doc/master/assets/images/wechat\.png)")' + r'apache/(?:incubator-)?hugegraph-doc/master/assets/images/wechat\.png)")' r'(?=[^>]*\bwidth="(?P200|300)")[^>]*?/?>', re.IGNORECASE, ) @@ -1048,10 +1410,19 @@ def apply_exact_legacy_content_fixes(assembly: pathlib.Path, version: str) -> in return fixed +def first_existing_path(assembly: pathlib.Path, candidates: tuple[str, ...]) -> pathlib.Path: + """Resolve one version-specific content path without guessing missing files.""" + matches = [assembly / candidate for candidate in candidates if (assembly / candidate).is_file()] + if len(matches) != 1: + fail(f"expected exactly one historical path, found {len(matches)}: {candidates!r}") + return matches[0] + + def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: fixed = 0 - if version in {"1.7", "1.5"}: - fixed += apply_exact_legacy_content_fixes(assembly, version) + if version in {"1.7", "1.5", "1.3", "1.0"}: + if version in LEGACY_EXACT_CONTENT_FIXES: + fixed += apply_exact_legacy_content_fixes(assembly, version) for language in ("en", "cn"): language_prefix = "/cn" if language == "cn" else "" legacy_metadata = ( @@ -1067,15 +1438,14 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: ), ) for relative, title, link_title in legacy_metadata: - fixed += ensure_frontmatter( - assembly / f"content/{language}/docs/{relative}", - title=title, - link_title=link_title, - weight=100, - ) - fixed += ensure_search_excluded( - assembly / f"content/{language}/docs/CLA.md" - ) + page = assembly / f"content/{language}/docs/{relative}" + if page.is_file(): + fixed += ensure_frontmatter( + page, title=title, link_title=link_title, weight=100 + ) + cla = assembly / f"content/{language}/docs/CLA.md" + if cla.is_file(): + fixed += ensure_search_excluded(cla) search_metadata = ( ( "config/config-option.md", @@ -1091,16 +1461,20 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: ), ) for relative, keywords in search_metadata: - fixed += ensure_search_metadata( - assembly / f"content/{language}/docs/{relative}", - keywords=keywords, - boost=1.5, - ) + page = assembly / f"content/{language}/docs/{relative}" + if page.is_file(): + fixed += ensure_search_metadata(page, keywords=keywords, boost=1.5) summary_path = assembly / f"content/{language}/docs/SUMMARY.md" - fixed += repair_historical_performance_routes(summary_path) + if version in {"1.7", "1.5"}: + fixed += repair_historical_performance_routes(summary_path) fixed += normalize_historical_server_headings( - assembly - / f"content/{language}/docs/quickstart/hugegraph/hugegraph-server.md" + first_existing_path( + assembly, + ( + f"content/{language}/docs/quickstart/hugegraph/hugegraph-server.md", + f"content/{language}/docs/quickstart/hugegraph-server.md", + ), + ) ) replacements = [] if language == "cn": @@ -1112,6 +1486,15 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: ) replacements.extend( [ + ("/en/docs/", "/docs/"), + ( + "/dcos/clients/restful-api", + f"{language_prefix}/docs/clients/restful-api/", + ), + ( + "/docs/quickstart/hugegraph-studio", + f"{language_prefix}/docs/quickstart/toolchain/hugegraph-hubble", + ), ( "/docs/quickstart/hugegraph-server", f"{language_prefix}/docs/quickstart/hugegraph/hugegraph-server", @@ -1120,6 +1503,10 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: "/clients/gremlin-console.html", f"{language_prefix}/docs/clients/gremlin-console/", ), + ( + "/clients/hugegraph-api.html", + f"{language_prefix}/docs/clients/restful-api/", + ), ( "./hugegraph-style.xml", "https://github.com/apache/hugegraph/blob/" @@ -1208,12 +1595,22 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: return fixed -def version_urls(manifest: dict, origin: str, language: str = "en") -> list[dict]: +def version_urls( + manifest: dict, + origin: str, + language: str = "en", + historical_origin: str | None = None, +) -> list[dict]: if language not in {"en", "cn"}: fail(f"unsupported version-menu language: {language}") language_prefix = "cn/" if language == "cn" else "" urls = [] for entry in manifest["versions"]: + entry_origin = ( + historical_origin + if entry["archived"] and historical_origin is not None + else origin + ) path = ( f"{entry['publishPath']}/{language_prefix}docs/" if entry["publishPath"] @@ -1223,20 +1620,25 @@ def version_urls(manifest: dict, origin: str, language: str = "en") -> list[dict { "version": entry["id"], "name": entry["name"], - "url": urllib.parse.urljoin(origin.rstrip("/") + "/", path), + "url": urllib.parse.urljoin(entry_origin.rstrip("/") + "/", path), "pagelinks": False, } ) return urls -def language_version_params(manifest: dict, origin: str, language: str) -> dict: +def language_version_params( + manifest: dict, + origin: str, + language: str, + historical_origin: str | None = None, +) -> dict: """Build language-preserving version selector and archive-banner params.""" if language not in {"en", "cn"}: fail(f"unsupported version-menu language: {language}") return { "version_menu": "Releases" if language == "en" else "版本", - "versions": version_urls(manifest, origin, language), + "versions": version_urls(manifest, origin, language, historical_origin), "url_latest_version": urllib.parse.urljoin( origin.rstrip("/") + "/", "cn/docs/" if language == "cn" else "docs/", @@ -1367,6 +1769,7 @@ def rewrite_internal_url( origin: str, publish_path: str, allowed_paths: set[str], + version_id: str | None = None, ) -> str: """Scope a same-site absolute/root URL to one historical artifact.""" if not value or value.startswith(("#", "?")): @@ -1417,8 +1820,8 @@ def rewrite_internal_url( else path ) normalized_internal = internal_path.rstrip("/") or "/" - mapped_path = KNOWN_HISTORICAL_ROUTES_BY_PUBLISH_PATH.get( - publish_path.strip("/"), {} + mapped_path = KNOWN_HISTORICAL_ROUTES_BY_VERSION.get( + version_id or "", {} ).get(normalized_internal) if mapped_path is None: mapped_path = KNOWN_HISTORICAL_ROUTES.get(normalized_internal) @@ -1550,6 +1953,51 @@ def scope_language_fallback_urls( return changed +def normalize_language_switch_urls( + action_data: dict, + relative: str, + artifact_base: str, + output: pathlib.Path, +) -> int: + """Keep language choices on an equivalent page or the same-version locale root.""" + actions = action_data.get("actions") + if not isinstance(actions, list): + return 0 + switches = [ + action + for action in actions + if isinstance(action, dict) and action.get("id") == "switch_language" + ] + if len(switches) != 1 or not isinstance(switches[0].get("options"), list): + return 0 + english_relative = relative.removeprefix("cn/") + chinese_relative = "cn/" + english_relative + candidates = {"en-US": english_relative, "zh-CN": chinese_relative} + roots = {"en-US": "", "zh-CN": "cn/"} + changed = 0 + for option in switches[0]["options"]: + language = option.get("id") + candidate = candidates.get(language) + if candidate is None: + continue + target = output / candidate + route = candidate + if not target.is_file(): + route = roots[language] + elif route.endswith("index.html"): + route = route[: -len("index.html")] + new_url = ( + "/" + route + if not target.is_file() + and urllib.parse.urlsplit(artifact_base).path == "/" + else urllib.parse.urljoin(artifact_base, route) + ) + if option.get("url") != new_url: + option["url"] = new_url + changed += 1 + return changed + + def validate_language_switch_contract( action_data: dict, relative: str, @@ -1607,6 +2055,47 @@ def validate_language_switch_contract( fail(f"language switch metadata mismatch in {relative}: {language}") +def rewrite_srcset_urls(value: str, rewrite) -> tuple[str, int]: + """Rewrite each URL in a srcset-like list while preserving its descriptors.""" + output: list[str] = [] + cursor = 0 + count = 0 + for old in srcset_urls(value): + start = value.find(old, cursor) + if start < 0: + fail(f"cannot locate srcset URL candidate: {old}") + new = rewrite(old) + output.extend((value[cursor:start], new)) + cursor = start + len(old) + count += old != new + output.append(value[cursor:]) + return "".join(output), count + + +def rewrite_url_list_attributes(text: str, rewrite) -> tuple[str, int]: + """Rewrite quoted and unquoted srcset/imagesrcset attribute candidates.""" + count = 0 + + def replace_quoted(match: re.Match) -> str: + nonlocal count + value, changed = rewrite_srcset_urls(match.group("value"), rewrite) + count += changed + return ( + f"{match.group('prefix')}{match.group('quote')}" + f"{value}{match.group('quote')}" + ) + + def replace_unquoted(match: re.Match) -> str: + nonlocal count + value, changed = rewrite_srcset_urls(match.group("value"), rewrite) + count += changed + return f"{match.group('prefix')}{value}" + + text = URL_LIST_ATTRIBUTE_RE.sub(replace_quoted, text) + text = URL_LIST_UNQUOTED_ATTRIBUTE_RE.sub(replace_unquoted, text) + return text, count + + def rewrite_text_urls(text: str, rewrite, *, markdown: bool) -> tuple[str, int]: count = 0 @@ -1619,51 +2108,670 @@ def replace_attribute(match: re.Match) -> str: f"{match.group('prefix')}{match.group('quote')}{new}{match.group('quote')}" ) + def replace_destination(match: re.Match) -> str: + nonlocal count + old = match.group("url") + new = rewrite(old) + count += old != new + return f"{match.group('open')}{new}{match.group('close')}" + + def rewrite_unfenced(source: str) -> str: + nonlocal count + source = URL_ATTRIBUTE_RE.sub(replace_attribute, source) + source, list_count = rewrite_url_list_attributes(source, rewrite) + count += list_count + return MARKDOWN_DESTINATION_RE.sub(replace_destination, source) + if markdown: in_fence = False - lines = [] + pending: list[str] = [] + chunks: list[str] = [] for line in text.splitlines(keepends=True): if re.match(r"^\s*(```|~~~)", line): + if pending: + chunks.append( + "".join(pending) + if in_fence + else rewrite_unfenced("".join(pending)) + ) + pending = [] + chunks.append(line) in_fence = not in_fence - lines.append(line) - continue - if not in_fence: - - def replace_destination(match: re.Match) -> str: - nonlocal count - old = match.group("url") - new = rewrite(old) - count += old != new - return f"{match.group('open')}{new}{match.group('close')}" - - line = URL_ATTRIBUTE_RE.sub(replace_attribute, line) - line = MARKDOWN_DESTINATION_RE.sub(replace_destination, line) - lines.append(line) - text = "".join(lines) + else: + pending.append(line) + if pending: + chunks.append( + "".join(pending) + if in_fence + else rewrite_unfenced("".join(pending)) + ) + text = "".join(chunks) else: - text = URL_ATTRIBUTE_RE.sub(replace_attribute, text) + text = rewrite_unfenced(text) return text, count +def normalize_logical_docs_route(_version: str, relative: str) -> str: + """Normalize reviewed route moves without treating aliases as pages.""" + normalized = relative.strip("/") + normalized = normalized.replace( + "performance/api-preformance", "performance/api-performance" + ) + return normalized + + +def docs_target_parts(target: str) -> tuple[str, str]: + """Return locale and relative Docs path for one route-map target.""" + normalized = target.strip("/") + if normalized == "docs": + return "en", "" + if normalized.startswith("docs/"): + return "en", normalized.removeprefix("docs/") + if normalized == "cn/docs": + return "cn", "" + if normalized.startswith("cn/docs/"): + return "cn", normalized.removeprefix("cn/docs/") + fail(f"route-map target is outside Docs: {target}") + + +def canonical_docs_pages(root: pathlib.Path, version: str) -> dict[str, str]: + """Inventory canonical Docs pages, excluding redirect aliases and print views.""" + pages: dict[str, str] = {} + for language, docs_root in ( + ("en", root / "docs"), + ("cn", root / "cn/docs"), + ): + if not docs_root.is_dir(): + fail(f"route-map Docs root is missing for {version}: {docs_root}") + for path in sorted(docs_root.rglob("index.html")): + document = DocumentParser() + document.feed(path.read_text(encoding="utf-8")) + if refresh_target(document): + continue + relative = path.parent.relative_to(docs_root).as_posix() + relative = "" if relative == "." else relative + logical_relative = normalize_logical_docs_route(version, relative) + logical_id = f"{language}:{logical_relative}" + target = ( + ("cn/docs/" if language == "cn" else "docs/") + + (relative.rstrip("/") + "/" if relative else "") + ) + previous = pages.get(logical_id) + if previous is not None and previous != target: + fail( + f"ambiguous canonical route for {version} {logical_id}: " + f"{previous} and {target}" + ) + pages[logical_id] = target + return pages + + +def docs_alias_equivalence_edges( + root: pathlib.Path, entry: dict, canonical_pages: dict[str, str] +) -> set[tuple[str, str]]: + """Return locale-preserving logical edges declared by static Docs aliases.""" + version = entry["id"] + prefix = "/" + entry["publishPath"].strip("/") if entry["publishPath"] else "" + metadata_path = root / ".version.json" + if metadata_path.is_file(): + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + expected_base = metadata.get("baseURL") + if not isinstance(expected_base, str) or not expected_base: + fail(f"route-map artifact baseURL is missing for {version}") + else: + expected_base = base_url("https://artifact.invalid/", entry["publishPath"]) + aliases: dict[str, tuple[str, str, pathlib.Path]] = {} + for language, docs_root in ( + ("en", root / "docs"), + ("cn", root / "cn/docs"), + ): + if not docs_root.is_dir(): + fail(f"route-map Docs root is missing for {version}: {docs_root}") + for path in sorted(docs_root.rglob("index.html")): + document = DocumentParser() + document.feed(path.read_text(encoding="utf-8")) + redirect = refresh_target(document) + if redirect is None: + continue + target_path = validate_artifact_alias_target( + redirect, + root, + entry, + expected_base, + path.relative_to(root).as_posix(), + ) + if prefix and ( + target_path == prefix or target_path.startswith(prefix + "/") + ): + target_path = target_path[len(prefix) :] or "/" + try: + target_language, target_relative = docs_target_parts(target_path) + except SystemExit: + continue + if target_language != language: + fail( + f"route-map alias changes locale for {version}: " + f"{path.relative_to(root)} -> {redirect}" + ) + source_relative = path.parent.relative_to(docs_root).as_posix() + source_relative = "" if source_relative == "." else source_relative + source_route = ( + ("cn/docs/" if language == "cn" else "docs/") + + (source_relative.rstrip("/") + "/" if source_relative else "") + ) + target_route = ( + ("cn/docs/" if target_language == "cn" else "docs/") + + (target_relative.rstrip("/") + "/" if target_relative else "") + ) + source_id = ( + f"{language}:" + f"{normalize_logical_docs_route(version, source_relative)}" + ) + previous = aliases.get(source_route) + alias = (source_id, target_route, path) + if previous is not None and previous != alias: + fail( + f"route-map alias is ambiguous for {version}: " + f"{source_route}" + ) + aliases[source_route] = alias + + canonical_ids_by_target = { + target: logical_id for logical_id, target in canonical_pages.items() + } + canonical_targets = set(canonical_ids_by_target) + edges: set[tuple[str, str]] = set() + for source_route, (source_id, target_route, path) in aliases.items(): + visited = {source_route} + candidate = target_route + while candidate not in canonical_targets: + if candidate in visited: + fail( + f"route-map alias cycle for {version}: " + f"{path.relative_to(root)} -> {candidate}" + ) + visited.add(candidate) + chained = aliases.get(candidate) + if chained is None: + fail( + f"route-map alias target is missing for {version}: " + f"{path.relative_to(root)} -> {candidate}" + ) + candidate = chained[1] + target_id = canonical_ids_by_target[candidate] + if source_id != target_id: + edges.add(tuple(sorted((source_id, target_id)))) + return edges + + +def equivalence_groups( + edges: set[tuple[str, str]], logical_ids: set[str] +) -> list[list[str]]: + """Build deterministic disjoint components from reviewed alias edges.""" + graph: dict[str, set[str]] = {} + for left, right in edges: + if left not in logical_ids or right not in logical_ids: + continue + graph.setdefault(left, set()).add(right) + graph.setdefault(right, set()).add(left) + groups = [] + unseen = set(graph) + while unseen: + pending = [min(unseen)] + component = set() + while pending: + logical_id = pending.pop() + if logical_id in component: + continue + component.add(logical_id) + pending.extend(sorted(graph.get(logical_id, ()), reverse=True)) + unseen.difference_update(component) + if len(component) > 1: + groups.append(sorted(component)) + return sorted(groups) + + +def generate_version_routes( + artifact_roots: dict[str, pathlib.Path], manifest: dict +) -> dict: + """Generate the reviewed cross-version logical page map deterministically.""" + version_ids = manifest_version_ids(manifest) + if set(artifact_roots) != set(version_ids): + fail( + "route-map artifacts must contain exactly: " + + ", ".join(version_ids) + ) + inventories = { + version: canonical_docs_pages(artifact_roots[version], version) + for version in version_ids + } + logical_ids = sorted( + {logical_id for pages in inventories.values() for logical_id in pages} + ) + entries = {entry["id"]: entry for entry in manifest["versions"]} + alias_edges = set().union( + *( + docs_alias_equivalence_edges( + artifact_roots[version], + entries[version], + inventories[version], + ) + for version in version_ids + ) + ) + result = { + "schemaVersion": 1, + "versions": list(version_ids), + "pages": { + logical_id: { + version: inventories[version].get(logical_id) + for version in version_ids + } + for logical_id in logical_ids + }, + "equivalents": equivalence_groups(alias_edges, set(logical_ids)), + } + validate_version_routes(result, manifest) + return result + + +def validate_version_routes(data: dict, manifest: dict) -> dict: + """Fail closed on route-map shape, ordering, locale, and target syntax.""" + version_ids = manifest_version_ids(manifest) + if not isinstance(data, dict) or set(data) != { + "schemaVersion", + "versions", + "pages", + "equivalents", + }: + fail("invalid version route-map top-level fields") + if data["schemaVersion"] != 1 or data["versions"] != list(version_ids): + fail("invalid version route-map schema or version order") + pages = data["pages"] + if not isinstance(pages, dict) or not pages: + fail("version route-map pages must be a non-empty object") + for logical_id, targets in pages.items(): + if ( + not isinstance(logical_id, str) + or ":" not in logical_id + or logical_id.split(":", 1)[0] not in {"en", "cn"} + ): + fail(f"invalid version route-map logical ID: {logical_id!r}") + language, relative = logical_id.split(":", 1) + if ( + relative.startswith("/") + or relative.endswith("/") + or ".." in pathlib.PurePosixPath(relative).parts + ): + fail(f"invalid version route-map logical path: {logical_id}") + if not isinstance(targets, dict) or list(targets) != list(version_ids): + fail(f"invalid version route-map target order: {logical_id}") + for version, target in targets.items(): + if target is None: + continue + if ( + not isinstance(target, str) + or target.startswith("/") + or not target.endswith("/") + or "?" in target + or "#" in target + or ".." in pathlib.PurePosixPath(target).parts + or target.startswith("versions/") + ): + fail( + f"invalid version route-map target for " + f"{logical_id} {version}: {target!r}" + ) + target_language, target_relative = docs_target_parts(target) + if target_language != language: + fail( + f"version route-map target changes locale: " + f"{logical_id} -> {target}" + ) + if normalize_logical_docs_route(version, target_relative) != relative: + fail( + f"version route-map target/logical mismatch for " + f"{logical_id} {version}: {target}" + ) + equivalents = data["equivalents"] + if not isinstance(equivalents, list): + fail("version route-map equivalents must be an array") + if not all( + isinstance(group, list) + and all(isinstance(logical_id, str) for logical_id in group) + for group in equivalents + ): + fail("invalid version route-map equivalent group types") + if equivalents != sorted(equivalents): + fail("version route-map equivalent groups must be ordered") + seen_equivalents: set[str] = set() + for group in equivalents: + if ( + not isinstance(group, list) + or len(group) < 2 + or group != sorted(group) + or len(group) != len(set(group)) + ): + fail(f"invalid version route-map equivalent group: {group!r}") + missing = set(group).difference(pages) + if missing: + fail( + "version route-map equivalent IDs are missing: " + + ", ".join(sorted(missing)) + ) + locales = {logical_id.split(":", 1)[0] for logical_id in group} + if len(locales) != 1: + fail(f"version route-map equivalent group changes locale: {group!r}") + overlap = seen_equivalents.intersection(group) + if overlap: + fail( + "version route-map equivalent ID is ambiguous: " + + ", ".join(sorted(overlap)) + ) + seen_equivalents.update(group) + for source_id in group: + for version in version_ids: + if pages[source_id][version] is not None: + continue + candidates = [ + pages[candidate_id][version] + for candidate_id in group + if candidate_id != source_id + and pages[candidate_id][version] is not None + ] + if len(candidates) > 1: + fail( + f"version route-map equivalent target is ambiguous for " + f"{source_id} {version}: {candidates!r}" + ) + return data + + +def load_version_routes( + path: pathlib.Path = VERSION_ROUTES, manifest: dict | None = None +) -> dict: + manifest = manifest or load_manifest(ROOT / "versions.json") + if not path.is_file(): + fail(f"version route-map is missing: {path}") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"cannot load version route-map {path}: {error}") + return validate_version_routes(data, manifest) + + +def canonical_docs_target(text: str, entry: dict) -> str | None: + """Resolve the current canonical page to an unscoped route-map target.""" + document = DocumentParser() + document.feed(text) + if len(document.canonical) != 1: + return None + path = urllib.parse.urlsplit(document.canonical[0]).path + prefix = "/" + entry["publishPath"].strip("/") if entry["publishPath"] else "" + if prefix and (path == prefix or path.startswith(prefix + "/")): + path = path[len(prefix) :] or "/" + normalized = path.strip("/") + if normalized == "docs" or normalized.startswith("docs/"): + return normalized + "/" + if normalized == "cn/docs" or normalized.startswith("cn/docs/"): + return normalized + "/" + return None + + +def logical_id_for_target( + route_map: dict, version: str, target: str +) -> str | None: + matches = [ + logical_id + for logical_id, targets in route_map["pages"].items() + if targets[version] == target + ] + if len(matches) > 1: + fail(f"ambiguous route-map target for {version}: {target}") + return matches[0] if matches else None + + +def resolved_logical_target( + route_map: dict, logical_id: str, version: str +) -> str | None: + """Prefer the canonical logical ID, then its unique reviewed equivalent.""" + direct = route_map["pages"][logical_id][version] + if direct is not None: + return direct + group = next( + ( + candidates + for candidates in route_map["equivalents"] + if logical_id in candidates + ), + (), + ) + alternatives = [ + route_map["pages"][candidate_id][version] + for candidate_id in group + if candidate_id != logical_id + and route_map["pages"][candidate_id][version] is not None + ] + if len(alternatives) > 1: + fail( + f"version route-map equivalent target is ambiguous for " + f"{logical_id} {version}: {alternatives!r}" + ) + return alternatives[0] if alternatives else None + + +def version_switch_options( + manifest: dict, + route_map: dict, + current_version: str, + current_target: str | None, + origin: str, + historical_origin: str | None = None, + *, + language: str | None = None, +) -> list[dict]: + """Return page-aware version choices with explicit missing-page fallbacks.""" + validate_version_routes(route_map, manifest) + logical_id = None + if current_target is not None: + target_language, _ = docs_target_parts(current_target) + language = target_language + logical_id = logical_id_for_target(route_map, current_version, current_target) + if logical_id is None: + fail( + f"current canonical Docs page is absent from route-map: " + f"{current_version} {current_target}" + ) + if language not in {"en", "cn"}: + fail(f"version switch locale is unavailable: {language!r}") + options = [] + for entry in manifest["versions"]: + entry_origin = ( + historical_origin + if entry["archived"] and historical_origin is not None + else origin + ) + equivalent = False + fallback = False + if logical_id is not None: + target = resolved_logical_target( + route_map, logical_id, entry["id"] + ) + if target is not None: + equivalent = True + url = urllib.parse.urljoin(entry_origin.rstrip("/") + "/", ( + f"{entry['publishPath'].rstrip('/')}/{target}" + if entry["publishPath"] + else target + )) + else: + fallback = True + root = "cn/docs/" if language == "cn" else "docs/" + path = ( + f"{entry['publishPath'].rstrip('/')}/{root}" + if entry["publishPath"] + else root + ) + url = ( + urllib.parse.urljoin(entry_origin.rstrip("/") + "/", path) + + "#hg-version-fallback" + ) + else: + root = "cn/docs/" if language == "cn" else "docs/" + path = ( + f"{entry['publishPath'].rstrip('/')}/{root}" + if entry["publishPath"] + else root + ) + url = urllib.parse.urljoin(entry_origin.rstrip("/") + "/", path) + options.append( + { + "id": entry["id"], + "title": entry["name"], + "url": url, + "active": entry["id"] == current_version, + "available": True, + "disabledReason": "", + "equivalent": equivalent, + "fallback": fallback, + } + ) + return options + + +def reviewed_version_route_urls( + manifest: dict, + route_map: dict, + origin: str, + historical_origin: str | None, +) -> set[str]: + """Return only cross-version destinations authorized by the route-map.""" + validate_version_routes(route_map, manifest) + urls: set[str] = set() + for logical_id, targets in route_map["pages"].items(): + language = logical_id.split(":", 1)[0] + if not any(target is not None for target in targets.values()): + fail(f"route-map page has no targets: {logical_id}") + for entry in manifest["versions"]: + entry_origin = ( + historical_origin + if entry["archived"] and historical_origin is not None + else origin + ) + target = resolved_logical_target( + route_map, logical_id, entry["id"] + ) + if target is None: + target = "cn/docs/" if language == "cn" else "docs/" + fragment = "#hg-version-fallback" + else: + fragment = "" + path = ( + f"{entry['publishPath'].rstrip('/')}/{target}" + if entry["publishPath"] + else target + ) + urls.add( + urllib.parse.urljoin(entry_origin.rstrip("/") + "/", path) + fragment + ) + return urls + + def scope_version_artifact( output: pathlib.Path, manifest: dict, entry: dict, origin: str, + historical_origin: str | None = None, ) -> dict: """Repair URL fields Hugo cannot canonify, then return auditable counts.""" - if not entry["publishPath"] and origin.rstrip("/") == CANONICAL_ORIGIN.rstrip("/"): - return {"files": 0, "urls": 0, "manifests": 0, "searchRefs": 0} + route_map = load_version_routes(manifest=manifest) allowed_paths = allowed_version_paths(manifest) artifact_base = base_url(origin, entry["publishPath"]) + reviewed_selector_urls = reviewed_version_route_urls( + manifest, route_map, origin, historical_origin + ) + historical_selector_urls: set[str] = set() + if historical_origin is not None: + archived_ids = { + item["id"] for item in manifest["versions"] if item["archived"] + } + for language in ("en", "cn"): + for item in version_urls( + manifest, origin, language, historical_origin + ): + if item["version"] in archived_ids: + historical_selector_urls.update( + (item["url"], item["url"].rstrip("/")) + ) def rewrite(value: str) -> str: - return rewrite_internal_url( + if value in reviewed_selector_urls or value in historical_selector_urls: + return value + rewritten = rewrite_internal_url( value, origin=origin, publish_path=entry["publishPath"], allowed_paths=allowed_paths, + version_id=entry["id"], ) + if entry.get("archived", bool(entry.get("publishPath"))): + original = urllib.parse.urlsplit(value) + original_path = original.path.rstrip("/") + "/" + version_prefix = "/" + entry["publishPath"] + if original_path.startswith(version_prefix + "/"): + original_path = original_path[len(version_prefix) :] + rewritten_parts = urllib.parse.urlsplit(rewritten) + scoped_relative = ( + rewritten_parts.path[len("/" + entry["publishPath"]) :] + if rewritten_parts.path.startswith("/" + entry["publishPath"]) + else rewritten_parts.path + ) + if ( + original_path in LATEST_SHARED_DOC_ROUTES + and not target_exists(output, scoped_relative) + ): + shared_origin = urllib.parse.urlsplit(origin.rstrip("/") + "/") + return urllib.parse.urlunsplit( + ( + shared_origin.scheme, + shared_origin.netloc, + original_path, + original.query, + original.fragment, + ) + ) + if not target_exists(output, scoped_relative): + counterpart = ( + "/cn" + scoped_relative + if scoped_relative.startswith("/docs/") + else scoped_relative.removeprefix("/cn") + if scoped_relative.startswith("/cn/docs/") + else "" + ) + if counterpart and target_exists(output, counterpart): + counterpart_path = "/" + entry["publishPath"] + counterpart + return urllib.parse.urlunsplit( + ( + urllib.parse.urlsplit(origin).scheme, + urllib.parse.urlsplit(origin).netloc, + counterpart_path, + original.query, + original.fragment, + ) + ) + if re.match( + r"^/(?:cn/)?docs/changelog/hugegraph-0\.[^/]+-release-notes/?$", + scoped_relative, + ): + language_prefix = "/cn" if scoped_relative.startswith("/cn/") else "" + changelog = f"{language_prefix}/docs/changelog/" + if target_exists(output, changelog): + return urllib.parse.urljoin( + base_url(origin, entry["publishPath"]), + changelog.lstrip("/"), + ) + return rewritten stats = {"files": 0, "urls": 0, "manifests": 0, "searchRefs": 0} for path in sorted(output.rglob("*")): @@ -1691,20 +2799,24 @@ def rewrite(value: str) -> str: ) stats["files"] += 1 continue - if path.suffix not in {".html", ".md", ".xml"}: + if path.suffix not in {".html", ".md", ".xml", ".txt"}: continue original = path.read_text(encoding="utf-8") rendered, changed = rewrite_text_urls( original, rewrite, - markdown=path.suffix == ".md", + markdown=path.suffix in {".md", ".txt"}, ) + relative = path.relative_to(output).as_posix() def replace_manifest(match: re.Match) -> str: data = json.loads(match.group("body")) rewritten = rewrite_json_urls(data, rewrite) - scope_language_fallback_urls( - rewritten, path.relative_to(output).as_posix(), artifact_base + normalize_language_switch_urls( + rewritten, + relative, + artifact_base, + output, ) stats["manifests"] += 1 return ( @@ -1780,6 +2892,134 @@ def write_historical_home_redirects(output: pathlib.Path, origin: str) -> int: return len(targets) +def exclude_historical_sitemaps(output: pathlib.Path) -> int: + """Keep archived pages directly reachable without advertising them for indexing.""" + removed = 0 + for path in sorted(output.rglob("sitemap.xml")): + path.unlink() + removed += 1 + robots = output / "robots.txt" + if robots.is_file(): + robots.write_text("User-agent: *\nAllow: /\n", encoding="utf-8") + return removed + + +def mark_historical_pages_noindex(output: pathlib.Path) -> int: + """Apply the archive indexing policy to every rendered historical page.""" + changed = 0 + for path in sorted(output.rglob("*.html")): + if path.name == "404.html": + continue + source = path.read_text(encoding="utf-8") + rendered, count = ROBOTS_META_RE.subn( + '', source + ) + if count == 0: + rendered, count = re.subn( + r"", + '', + rendered, + count=1, + flags=re.IGNORECASE, + ) + if count != 1: + fail(f"historical page must contain one head/robots marker: {path}") + if count: + path.write_text(rendered, encoding="utf-8") + changed += count + return changed + + +def write_historical_route_aliases( + output: pathlib.Path, origin: str, publish_path: str +) -> int: + """Create deterministic locale-aware redirects for migrated flat routes.""" + written = 0 + site_base = base_url(origin, publish_path) + for language_prefix in ("", "cn/"): + for old_relative, new_relative in LEGACY_IA_ROUTE_MAP.items(): + old_route = language_prefix + "docs/" + old_relative.removesuffix(".md") + old_route = old_route.replace("/README", "") + new_route = language_prefix + "docs/" + new_relative.removesuffix(".md") + target = output / new_route / "index.html" + if not target.is_file(): + continue + alias = output / old_route / "index.html" + if alias.exists(): + fail(f"historical alias collides with rendered output: {alias}") + alias.parent.mkdir(parents=True, exist_ok=True) + target_url = urllib.parse.urljoin(site_base, new_route.rstrip("/") + "/") + escaped = html.escape(target_url, quote=True) + alias.write_text( + "\n" + '\n' + '\n' + f'\n' + f'\n' + f'Continue\n', + encoding="utf-8", + ) + written += 1 + return written + + +def remove_non_equivalent_hreflang( + output: pathlib.Path, origin: str, publish_path: str +) -> int: + """Remove Hugo language fallbacks from hreflang while keeping real translations.""" + removed = 0 + prefix = "/" + publish_path.strip("/") if publish_path else "" + origin_parts = urllib.parse.urlsplit(origin.rstrip("/") + "/") + for path in sorted(output.rglob("*.html")): + relative = path.relative_to(output).as_posix() + if relative in {"404.html", "cn/404.html"} or "_print/" in relative: + continue + current_url = public_url_for_file(path, output, origin, publish_path) + current_path = urllib.parse.urlsplit(current_url).path + cn_prefix = prefix + "/cn/" + if current_path.startswith(cn_prefix): + english_path = prefix + current_path[len(prefix + "/cn") :] + else: + english_path = current_path + chinese_path = ( + prefix + "/cn/" + if english_path.rstrip("/") == prefix + else prefix + "/cn" + english_path[len(prefix) :] + ) + expected_paths = {"en-US": english_path, "zh-CN": chinese_path} + + def keep_equivalent(match: re.Match) -> str: + nonlocal removed + parser = DocumentParser() + parser.feed(match.group(0)) + if len(parser.hreflang) != 1: + fail(f"cannot parse hreflang link in {relative}: {match.group(0)}") + language, href = parser.hreflang[0] + expected_path = expected_paths.get(language) + href_parts = urllib.parse.urlsplit( + urllib.parse.urljoin(current_url, href) + ) + local_relative = ( + expected_path[len(prefix) :] if prefix and expected_path else expected_path + ) + if ( + expected_path is not None + and href_parts.scheme == origin_parts.scheme + and href_parts.netloc == origin_parts.netloc + and href_parts.path == expected_path + and target_exists(output, local_relative or "/") + ): + return match.group(0) + removed += 1 + return "" + + source = path.read_text(encoding="utf-8") + rendered = HREFLANG_LINK_RE.sub(keep_equivalent, source) + if rendered != source: + path.write_text(rendered, encoding="utf-8") + return removed + + def public_url_for_file( path: pathlib.Path, root: pathlib.Path, @@ -1804,6 +3044,87 @@ def target_exists(root: pathlib.Path, relative: str) -> bool: return candidate.is_file() or (candidate / "index.html").is_file() +def validate_artifact_alias_target( + value: str, + root: pathlib.Path, + entry: dict, + expected_base: str, + source: str, +) -> str: + """Require an ordinary refresh alias to resolve inside its version artifact.""" + if ( + not value + or not value.startswith(("http://", "https://", "/")) + or any(char in value for char in ("?", "#", "%", "\\")) + or any( + char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F + for char in value + ) + ): + fail(f"alias target is not canonical in {source}: {value}") + absolute_http = value.startswith(("http://", "https://")) + require_safe_url_syntax(value) + if not require_safe_url_scheme(value, source): + fail(f"alias target uses a non-HTTP protocol in {source}: {value}") + try: + target = urllib.parse.urlsplit(value) + base = urllib.parse.urlsplit(expected_base) + require_safe_http_authority(target, value) + require_safe_http_authority(base, expected_base) + except (TypeError, ValueError) as exc: + fail(f"alias target is malformed in {source}: {value}: {exc}") + if ( + base.scheme.lower() not in {"http", "https"} + or not base.netloc + or base.query + or base.fragment + ): + fail(f"artifact baseURL is malformed for {entry['id']}: {expected_base}") + if absolute_http and (not target.scheme or not target.netloc): + fail(f"absolute alias target has no authority in {source}: {value}") + if target.netloc and ( + target.scheme.lower() != base.scheme.lower() + or target.netloc.lower() != base.netloc.lower() + ): + fail(f"alias target leaves the configured origin in {source}: {value}") + path = target.path + segments = path.split("/") + if ( + not path.startswith("/") + or "//" in path + or any(segment in {".", ".."} for segment in segments) + ): + fail(f"alias target path is unsafe in {source}: {value}") + prefix = "/" + entry["publishPath"].strip("/") if entry["publishPath"] else "" + if prefix and not (path == prefix or path.startswith(prefix + "/")): + fail(f"alias target escapes version {entry['id']} in {source}: {value}") + relative = path[len(prefix) :] if prefix else path + if not target_exists(root, relative): + fail(f"alias target is missing in {source}: {value}") + return path + + +def validate_historical_home_alias_target( + value: str, + relative: str, + site_origin: str, +) -> None: + """Allow only the two reviewed archive-home redirects to shared latest pages.""" + suffixes = { + "index.html": "", + "cn/index.html": "cn/", + } + suffix = suffixes.get(relative) + if suffix is None: + fail(f"unexpected historical home alias path: {relative}") + expected = urllib.parse.urljoin(site_origin.rstrip("/") + "/", suffix) + if value != expected: + fail( + f"historical home alias target changed in {relative}: " + f"{value} != {expected}" + ) + + def iter_json_strings(value): if isinstance(value, dict): for item in value.values(): @@ -1859,8 +3180,170 @@ def require_docs_navigation_json(data: dict, source: str, language: str) -> None fail(f"private Docs navigation route leaked into {source}") +def validate_llms_full_outputs( + root: pathlib.Path, entry: dict, expected_base: str +) -> None: + """Require locale-bound latest corpora and prohibit them in archives.""" + outputs = { + "en": root / "docs/llms-full.txt", + "cn": root / "cn/docs/llms-full.txt", + } + if entry["archived"]: + leaked = [ + path.relative_to(root).as_posix() + for path in outputs.values() + if path.exists() + ] + if leaked: + fail( + "historical artifact must not contain LLMSFULL output: " + + ", ".join(leaked) + ) + return + contracts = { + "en": ( + urllib.parse.urljoin(expected_base, "docs/index.md"), + "LLMS index:", + "](/llms.txt)", + "LLMS 索引:", + ), + "cn": ( + urllib.parse.urljoin(expected_base, "cn/docs/index.md"), + "LLMS 索引:", + "](/cn/llms.txt)", + "LLMS index:", + ), + } + for language, path in outputs.items(): + label = "English" if language == "en" else "Chinese" + if not path.is_file(): + fail(f"{label} LLMSFULL output is missing: {path.relative_to(root)}") + text = path.read_text(encoding="utf-8") + canonical_source, marker, index_link, forbidden = contracts[language] + if ( + marker not in text + or index_link not in text + or forbidden in text + ): + fail(f"{label} LLMSFULL locale contract is invalid") + source_rows = [ + line for line in text.splitlines() if line.startswith("Source:") + ] + sources = [line.removeprefix("Source:").strip() for line in source_rows] + if ( + not sources + or any(not line.startswith("Source: ") for line in source_rows) + or any(not value for value in sources) + ): + fail(f"{label} LLMSFULL source is missing or malformed") + if sources[0] != canonical_source: + fail(f"{label} LLMSFULL canonical source is not first") + expected_parts = urllib.parse.urlsplit(expected_base) + locale_root = "cn/docs/" if language == "cn" else "docs/" + expected_path_prefix = urllib.parse.urlsplit( + urllib.parse.urljoin(expected_base, locale_root) + ).path + canonical_sources: set[str] = set() + for value in sources: + if "?" in value or "#" in value or "%" in value: + fail(f"{label} LLMSFULL source is not canonical: {value}") + try: + require_safe_url_syntax(value) + parts = urllib.parse.urlsplit(value) + require_safe_http_authority(parts, value) + except (TypeError, ValueError) as exc: + fail(f"{label} LLMSFULL source is malformed: {value}: {exc}") + decoded_path = urllib.parse.unquote(parts.path) + path_segments = parts.path.split("/") + if ( + any(char.isspace() for char in value) + or parts.scheme.lower() != expected_parts.scheme.lower() + or parts.netloc != expected_parts.netloc + or parts.query + or parts.fragment + or "\\" in decoded_path + or "//" in parts.path + or any(segment in {".", ".."} for segment in path_segments) + or not decoded_path.startswith(expected_path_prefix) + or not decoded_path.endswith(".md") + or ".." in pathlib.PurePosixPath(decoded_path).parts + ): + fail(f"{label} LLMSFULL source is outside its artifact: {value}") + canonical = urllib.parse.urlunsplit( + ( + parts.scheme.lower(), + parts.netloc.lower(), + decoded_path, + "", + "", + ) + ) + if canonical in canonical_sources: + fail(f"{label} LLMSFULL source is duplicated: {value}") + canonical_sources.add(canonical) + + +def validate_social_image_metadata( + document: DocumentParser, + relative: str, + root: pathlib.Path, + expected_base: str, + *, + allow_missing: bool = False, +) -> None: + """Require matching same-artifact Open Graph and Twitter image targets.""" + values: dict[str, list[str]] = {"og:image": [], "twitter:image": []} + for item in document.meta: + if item.get("property", "").lower() == "og:image": + values["og:image"].append(item.get("content", "")) + if item.get("name", "").lower() == "twitter:image": + values["twitter:image"].append(item.get("content", "")) + if allow_missing and not values["og:image"] and not values["twitter:image"]: + return + if any(len(items) != 1 or not items[0] for items in values.values()): + fail(f"social image metadata is missing or duplicated in {relative}") + og_image = values["og:image"][0] + twitter_image = values["twitter:image"][0] + if og_image != twitter_image: + fail(f"social image metadata differs in {relative}") + value = og_image + if "?" in value or "#" in value or "%" in value or "\\" in value: + fail(f"social image URL is not canonical in {relative}: {value}") + try: + parts = urllib.parse.urlsplit(value) + base = urllib.parse.urlsplit(expected_base) + require_safe_http_authority(parts, value) + except (TypeError, ValueError) as exc: + fail(f"social image URL is malformed in {relative}: {value}: {exc}") + if parts.scheme: + if ( + parts.scheme.lower() != "https" + or parts.netloc.lower() != base.netloc.lower() + ): + fail(f"social image URL is outside the artifact in {relative}: {value}") + elif parts.netloc or not value.startswith("/"): + fail(f"social image URL must be absolute in {relative}: {value}") + base_path = base.path.rstrip("/") + "/" + image_path = parts.path + if not image_path.startswith(base_path): + fail(f"social image URL is outside the artifact in {relative}: {value}") + artifact_relative = image_path[len(base_path) :] + suffix = pathlib.PurePosixPath(artifact_relative).suffix.lower() + if ( + not artifact_relative + or artifact_relative.startswith("/") + or ".." in pathlib.PurePosixPath(artifact_relative).parts + or suffix not in {".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"} + ): + fail(f"social image URL is not an image in {relative}: {value}") + target = root / pathlib.PurePosixPath(artifact_relative) + if not target.is_file(): + fail(f"social image target is missing in {relative}: {value}") + + def validate_artifact(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) + route_map = load_version_routes(manifest=manifest) entry = next( (item for item in manifest["versions"] if item["id"] == args.version), None ) @@ -1877,6 +3360,8 @@ def validate_artifact(args: argparse.Namespace) -> None: require_metadata_matches(expected_entry, metadata, metadata_path) if metadata.get("baseURL") != expected_base: fail(f"version metadata does not match {entry['id']} at {expected_base}") + validate_output_security(root, expected_base) + validate_llms_full_outputs(root, entry, expected_base) docs_navigation = metadata.get("docsNavigation") expected_docs_navigation = DOCS_NAV_EXPECTED_STATS[entry["id"]] if docs_navigation != expected_docs_navigation: @@ -1903,6 +3388,12 @@ def validate_artifact(args: argparse.Namespace) -> None: canonical_host = same_site_host(canonical_parts) prefix = "/" + entry["publishPath"].strip("/") if entry["publishPath"] else "" allowed_paths = allowed_version_paths(manifest) + reviewed_route_urls = reviewed_version_route_urls( + manifest, + route_map, + args.site_origin, + getattr(args, "historical_origin", None), + ) current_docs = (prefix + "/docs").rstrip("/") or "/docs" checked_urls = 0 canonical_pages = 0 @@ -1951,6 +3442,9 @@ def validate_url(value: str, source: pathlib.Path) -> None: nonlocal checked_urls if not value or value.startswith(("#", "?")): return + if value in reviewed_route_urls: + checked_urls += 1 + return source_name = source.relative_to(root).as_posix() require_safe_url_syntax(value) if not require_safe_url_scheme(value, source_name): @@ -1965,6 +3459,18 @@ def validate_url(value: str, source: pathlib.Path) -> None: ): fail(f"unsafe same-site URL authority in {source_name}: {value}") if parsed_host == canonical_host and parsed_host != origin_host: + historical_docs_prefixes = tuple( + "/" + item["publishPath"] + suffix + for item in manifest["versions"] + if item["archived"] + for suffix in ("/docs", "/cn/docs") + ) + if any( + parsed.path == prefix or parsed.path.startswith(prefix + "/") + for prefix in historical_docs_prefixes + ): + checked_urls += 1 + return fail(f"production-origin URL leaked into staging {source_name}: {value}") if parsed.netloc and parsed_host != origin_host: return @@ -1991,6 +3497,10 @@ def validate_url(value: str, source: pathlib.Path) -> None: ) path = parsed.path or "/" normalized = path.rstrip("/") or "/" + normalized_with_slash = normalized.rstrip("/") + "/" + if normalized_with_slash in LATEST_SHARED_DOC_ROUTES: + checked_urls += 1 + return if ( parsed.netloc and (normalized in allowed_paths or is_latest_shared_path(normalized)) @@ -2119,10 +3629,40 @@ def validate_url(value: str, source: pathlib.Path) -> None: fail(f"404 page does not use the interactive OINK shell: {relative}") document = DocumentParser() document.feed(text) - require_toc_accessible_name(document, relative) alias_target = refresh_target(document) + validate_social_image_metadata( + document, + relative, + root, + expected_base, + allow_missing=alias_target is not None, + ) + require_toc_accessible_name(document, relative) + if entry["archived"] and relative not in {"404.html", "cn/404.html"}: + robots = [ + re.sub(r"\s+", "", item.get("content", "").lower()) + for item in document.meta + if item.get("name", "").lower() == "robots" + ] + if robots != ["noindex,follow"]: + fail(f"historical page must be noindex,follow: {relative}: {robots!r}") if alias_target: - validate_url(alias_target, path) + if relative != "client-go/index.html": + if entry["archived"] and relative in {"index.html", "cn/index.html"}: + validate_historical_home_alias_target( + alias_target, + relative, + args.site_origin, + ) + else: + validate_artifact_alias_target( + alias_target, + root, + entry, + expected_base, + relative, + ) + checked_urls += 1 action_data = {} manifests = list(ACTION_MANIFEST_RE.finditer(text)) if manifests: @@ -2160,25 +3700,22 @@ def validate_url(value: str, source: pathlib.Path) -> None: None, ) language = "cn" if relative.startswith("cn/") else "en" - expected_options = version_urls(manifest, args.site_origin, language) - if switch is None or [ - ( - item.get("id"), - item.get("title"), - str(item.get("url", "")).rstrip("/"), - item.get("active"), - ) - for item in switch.get("options", []) - ] != [ - ( - item["version"], - item["name"], - item["url"].rstrip("/"), - item["version"] == entry["id"], - ) - for item in expected_options - ]: + current_target = canonical_docs_target(text, entry) + expected_options = version_switch_options( + manifest, + route_map, + entry["id"], + current_target, + args.site_origin, + getattr(args, "historical_origin", None), + language=language, + ) + if switch is None or switch.get("options") != expected_options: fail(f"version switch contract mismatch in {path.relative_to(root)}") + if not relative.startswith(("_print/", "cn/_print/")): + require_version_switch_matches_native( + document, expected_options, relative + ) if ( entry["archived"] and relative not in archive_exceptions @@ -2265,7 +3802,7 @@ def validate_url(value: str, source: pathlib.Path) -> None: if english_path.rstrip("/") == prefix else prefix + "/cn" + english_path[len(prefix) :] ) - expected_hreflang = { + equivalent_urls = { "en-US": urllib.parse.urlunsplit( (origin_parts.scheme, origin_parts.netloc, english_path, "", "") ), @@ -2273,10 +3810,15 @@ def validate_url(value: str, source: pathlib.Path) -> None: (origin_parts.scheme, origin_parts.netloc, chinese_path, "", "") ), } - for language, fallback_path in HREFLANG_FALLBACKS.get(relative, {}).items(): - expected_hreflang[language] = urllib.parse.urljoin( - expected_base, fallback_path.lstrip("/") - ) + equivalent_paths = { + "en-US": english_path[len(prefix) :] if prefix else english_path, + "zh-CN": chinese_path[len(prefix) :] if prefix else chinese_path, + } + expected_hreflang = { + language: url + for language, url in equivalent_urls.items() + if target_exists(root, equivalent_paths[language]) + } if actual_hreflang != expected_hreflang: fail( f"hreflang mismatch in {relative}: " @@ -2285,7 +3827,12 @@ def validate_url(value: str, source: pathlib.Path) -> None: validate_language_switch_contract( action_data, relative, - expected_hreflang, + { + "en-US": expected_hreflang.get("en-US", expected_base), + "zh-CN": expected_hreflang.get( + "zh-CN", urllib.parse.urljoin(expected_base, "cn/") + ), + }, current_path, "zh-CN" if relative.startswith("cn/") else "en-US", ) @@ -2337,6 +3884,64 @@ def validate_url(value: str, source: pathlib.Path) -> None: ) +def derived_version_config( + manifest: dict, + entry: dict, + site_origin: str, + historical_origin: str | None = None, +) -> dict: + """Derive every Hugo version-menu value from versions.json.""" + override = { + "baseURL": base_url(site_origin, entry["publishPath"]), + "canonifyURLs": True, + "params": { + "version": entry["id"], + "version_menu": "Releases", + "version_menu_pagelinks": False, + "versions": version_urls( + manifest, site_origin, "en", historical_origin + ), + "archived_version": bool(entry["archived"]), + "url_latest_version": urllib.parse.urljoin( + site_origin.rstrip("/") + "/", "docs/" + ), + "github_repo": "https://github.com/apache/hugegraph-doc", + "github_branch": entry["githubBranch"], + }, + } + language_overrides = ( + historical_language_menus(site_origin) + if entry["archived"] + else {"en": {}, "cn": {}} + ) + for language in ("en", "cn"): + language_overrides[language]["params"] = language_version_params( + manifest, + site_origin, + language, + historical_origin, + ) + override["languages"] = language_overrides + return override + + +def render_config(args: argparse.Namespace) -> None: + """Write the manifest-derived override used by direct Hugo commands.""" + manifest = load_manifest(args.manifest) + version_id = args.version or manifest["versions"][0]["id"] + entry = next( + (item for item in manifest["versions"] if item["id"] == version_id), None + ) + if entry is None: + fail(f"unknown version {version_id}") + override = derived_version_config( + manifest, entry, args.site_origin, args.historical_origin + ) + rendered = json.dumps(override, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + args.output.write_text(rendered, encoding="utf-8") + print(f"rendered Hugo config for {entry['id']} -> {args.output}") + + def build(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) entry = next( @@ -2379,32 +3984,9 @@ def build(args: argparse.Namespace) -> None: origin=args.site_origin, ) site_base = base_url(args.site_origin, entry["publishPath"]) - override = { - "baseURL": site_base, - "canonifyURLs": True, - "params": { - "version": entry["id"], - "version_menu": "Releases", - "version_menu_pagelinks": False, - "versions": version_urls(manifest, args.site_origin, "en"), - "archived_version": bool(entry["archived"]), - "url_latest_version": urllib.parse.urljoin( - args.site_origin.rstrip("/") + "/", "docs/" - ), - "github_repo": "https://github.com/apache/hugegraph-doc", - "github_branch": entry["githubBranch"], - }, - } - language_overrides = ( - historical_language_menus(args.site_origin) - if entry["archived"] - else {"en": {}, "cn": {}} + override = derived_version_config( + manifest, entry, args.site_origin, args.historical_origin ) - for language in ("en", "cn"): - language_overrides[language]["params"] = language_version_params( - manifest, args.site_origin, language - ) - override["languages"] = language_overrides override_path = assembly / "version-config.json" override_path.write_text( json.dumps(override, ensure_ascii=False), encoding="utf-8" @@ -2456,6 +4038,9 @@ def build(args: argparse.Namespace) -> None: cwd=assembly, check=True, ) + route_migrations = migrate_legacy_information_architecture( + assembly, entry["id"] + ) known_fixes = apply_known_legacy_fixes(assembly, entry["id"]) docs_navigation = materialize_docs_navigation(assembly, entry["publishPath"]) subprocess.run( @@ -2494,11 +4079,28 @@ def build(args: argparse.Namespace) -> None: go_directory + os.pathsep + build_environment.get("PATH", "") ) subprocess.run(command, cwd=assembly, check=True, env=build_environment) + route_aliases = ( + write_historical_route_aliases( + output, args.site_origin, entry["publishPath"] + ) + if entry["id"] in {"1.3", "1.0"} + else 0 + ) + archived_noindex = ( + mark_historical_pages_noindex(output) if entry["archived"] else 0 + ) + non_equivalent_hreflang = remove_non_equivalent_hreflang( + output, args.site_origin, entry["publishPath"] + ) + historical_sitemaps = ( + exclude_historical_sitemaps(output) if entry["archived"] else 0 + ) url_scoping = scope_version_artifact( output, manifest, entry, args.site_origin, + args.historical_origin, ) url_scoping["historicalHomeRedirects"] = ( write_historical_home_redirects(output, args.site_origin) @@ -2534,10 +4136,15 @@ def build(args: argparse.Namespace) -> None: for item in migration_data.get("files", []) ), "knownFixes": known_fixes, + "routeMigrations": route_migrations, "residual": 0, }, "docsNavigation": docs_navigation, "urlScoping": url_scoping, + "historicalSitemapsRemoved": historical_sitemaps, + "historicalNoindexPages": archived_noindex, + "historicalRouteAliases": route_aliases, + "nonEquivalentHreflangRemoved": non_equivalent_hreflang, } ) (output / ".version.json").write_text( @@ -2558,13 +4165,6 @@ def write_aggregate_sitemap(output: pathlib.Path, origin: str, manifest: dict) - urllib.parse.urljoin(origin.rstrip("/") + "/", "en/sitemap.xml"), urllib.parse.urljoin(origin.rstrip("/") + "/", "cn/sitemap.xml"), ] - for entry in manifest["versions"]: - if entry["publishPath"]: - locations.append( - urllib.parse.urljoin( - origin.rstrip("/") + "/", entry["publishPath"] + "/sitemap.xml" - ) - ) root = ET.Element( "sitemapindex", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ) @@ -2638,14 +4238,71 @@ def validate_output_security(output: pathlib.Path, site_origin: str) -> None: ) +def validate_aggregate_version_routes( + output: pathlib.Path, + route_map: dict, + selected: set[str], + manifest: dict, +) -> None: + """Prove every selected route target exists and every selected null is real.""" + validate_version_routes(route_map, manifest) + version_ids = manifest_version_ids(manifest) + publish_paths = { + entry["id"]: entry["publishPath"] for entry in manifest["versions"] + } + selected = set(selected) + unknown = selected.difference(version_ids) + if unknown: + fail(f"cannot validate unknown route-map versions: {sorted(unknown)!r}") + for version in version_ids: + if version not in selected: + continue + if version not in publish_paths: + fail(f"route-map publish path is missing for {version}") + version_root = output / publish_paths[version] + actual = canonical_docs_pages(version_root, version) + for logical_id, targets in route_map["pages"].items(): + expected = targets[version] + observed = actual.get(logical_id) + if expected is None: + if observed is not None: + fail( + f"route-map null target exists for " + f"{version} {logical_id}: {observed}" + ) + continue + if observed is None: + fail( + f"route-map target is missing for " + f"{version} {logical_id}: {expected}" + ) + if observed != expected: + fail( + f"route-map target drift for {version} {logical_id}: " + f"{observed} != {expected}" + ) + extras = sorted(set(actual).difference(route_map["pages"])) + if extras: + fail( + f"canonical Docs pages are absent from route-map for " + f"{version}: {extras}" + ) + + def aggregate(args: argparse.Namespace) -> None: manifest = load_resolved_manifest(args.resolved_manifest) + selected = selected_version_ids(getattr(args, "select", None), manifest) output = prepare_output_directory(args.output, "aggregate output") output.mkdir(parents=True) seen: set[str] = set() resolved = [] for entry in manifest["versions"]: - source = args.artifacts / f"{args.artifact_prefix}{entry['id']}" + if entry["id"] not in selected: + continue + source = args.artifacts / ( + f"{args.artifact_prefix}{entry['id']}" + f"{getattr(args, 'artifact_suffix', '')}" + ) metadata_path = source / ".version.json" if not metadata_path.is_file(): fail(f"missing version metadata: {metadata_path}") @@ -2657,6 +4314,7 @@ def aggregate(args: argparse.Namespace) -> None: version=entry["id"], sha=entry["sha"], site_origin=args.site_origin, + historical_origin=getattr(args, "historical_origin", None), artifact=source, ) ) @@ -2698,6 +4356,17 @@ def aggregate(args: argparse.Namespace) -> None: + "\n", encoding="utf-8", ) + route_map = load_version_routes(manifest=manifest) + (metadata_dir / "version-routes.json").write_text( + json.dumps(route_map, ensure_ascii=False, sort_keys=False, indent=2) + "\n", + encoding="utf-8", + ) + validate_aggregate_version_routes( + output, + route_map, + selected, + manifest, + ) validate_output_security(output, args.site_origin) print( f"aggregated {len(resolved)} versions and {len(seen)} files " @@ -2705,6 +4374,23 @@ def aggregate(args: argparse.Namespace) -> None: ) +def generate_routes(args: argparse.Namespace) -> None: + manifest = load_manifest(args.manifest) + version_ids = manifest_version_ids(manifest) + roots = { + version: args.artifacts + / f"{args.artifact_prefix}{version}{args.artifact_suffix}" + for version in version_ids + } + data = generate_version_routes(roots, manifest) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(data, ensure_ascii=False, sort_keys=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"generated {len(data['pages'])} logical version routes -> {args.output}") + + def parser() -> argparse.ArgumentParser: result = argparse.ArgumentParser(description=__doc__) result.add_argument("--manifest", type=pathlib.Path, default=ROOT / "versions.json") @@ -2713,13 +4399,22 @@ def parser() -> argparse.ArgumentParser: prepare_parser = commands.add_parser("prepare") prepare_parser.add_argument("--local", action="store_true") prepare_parser.add_argument("--latest-sha", default="HEAD") + prepare_parser.add_argument("--select") prepare_parser.add_argument("--output", type=pathlib.Path) prepare_parser.set_defaults(func=prepare) + config_parser = commands.add_parser("config") + config_parser.add_argument("--version") + config_parser.add_argument("--site-origin", default=CANONICAL_ORIGIN) + config_parser.add_argument("--historical-origin") + config_parser.add_argument("--output", type=pathlib.Path, required=True) + config_parser.set_defaults(func=render_config) + build_parser = commands.add_parser("build") build_parser.add_argument("--version", required=True) build_parser.add_argument("--sha", required=True) build_parser.add_argument("--site-origin", required=True) + build_parser.add_argument("--historical-origin") build_parser.add_argument("--output", type=pathlib.Path, required=True) build_parser.set_defaults(func=build) @@ -2727,20 +4422,33 @@ def parser() -> argparse.ArgumentParser: validate_parser.add_argument("--version", required=True) validate_parser.add_argument("--sha", required=True) validate_parser.add_argument("--site-origin", required=True) + validate_parser.add_argument("--historical-origin") validate_parser.add_argument("--artifact", type=pathlib.Path, required=True) validate_parser.set_defaults(func=validate_artifact) aggregate_parser = commands.add_parser("aggregate") aggregate_parser.add_argument("--artifacts", type=pathlib.Path, required=True) aggregate_parser.add_argument("--artifact-prefix", default="") + aggregate_parser.add_argument("--artifact-suffix", default="") aggregate_parser.add_argument( "--resolved-manifest", type=pathlib.Path, required=True ) aggregate_parser.add_argument("--site-origin", required=True) + aggregate_parser.add_argument("--historical-origin") + aggregate_parser.add_argument("--select") aggregate_parser.add_argument("--output", type=pathlib.Path, required=True) aggregate_parser.add_argument("--asf-profile") aggregate_parser.add_argument("--asf-whoami") aggregate_parser.set_defaults(func=aggregate) + + routes_parser = commands.add_parser("routes") + routes_parser.add_argument("--artifacts", type=pathlib.Path, required=True) + routes_parser.add_argument("--artifact-prefix", default="") + routes_parser.add_argument("--artifact-suffix", default="") + routes_parser.add_argument( + "--output", type=pathlib.Path, default=VERSION_ROUTES + ) + routes_parser.set_defaults(func=generate_routes) return result diff --git a/static/img/bootstrap-controls/accordion-active-dark.svg b/static/img/bootstrap-controls/accordion-active-dark.svg new file mode 100644 index 000000000..3d4bc8f71 --- /dev/null +++ b/static/img/bootstrap-controls/accordion-active-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/accordion-active.svg b/static/img/bootstrap-controls/accordion-active.svg new file mode 100644 index 000000000..84298341e --- /dev/null +++ b/static/img/bootstrap-controls/accordion-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/accordion-dark.svg b/static/img/bootstrap-controls/accordion-dark.svg new file mode 100644 index 000000000..3d4bc8f71 --- /dev/null +++ b/static/img/bootstrap-controls/accordion-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/accordion.svg b/static/img/bootstrap-controls/accordion.svg new file mode 100644 index 000000000..1c53db8c3 --- /dev/null +++ b/static/img/bootstrap-controls/accordion.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/carousel-next.svg b/static/img/bootstrap-controls/carousel-next.svg new file mode 100644 index 000000000..75822e384 --- /dev/null +++ b/static/img/bootstrap-controls/carousel-next.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/carousel-prev.svg b/static/img/bootstrap-controls/carousel-prev.svg new file mode 100644 index 000000000..27a6b32a6 --- /dev/null +++ b/static/img/bootstrap-controls/carousel-prev.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/check-checked.svg b/static/img/bootstrap-controls/check-checked.svg new file mode 100644 index 000000000..55c82bbbc --- /dev/null +++ b/static/img/bootstrap-controls/check-checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/check-indeterminate.svg b/static/img/bootstrap-controls/check-indeterminate.svg new file mode 100644 index 000000000..8769f308d --- /dev/null +++ b/static/img/bootstrap-controls/check-indeterminate.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/close.svg b/static/img/bootstrap-controls/close.svg new file mode 100644 index 000000000..fca539ec7 --- /dev/null +++ b/static/img/bootstrap-controls/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/navbar-dark.svg b/static/img/bootstrap-controls/navbar-dark.svg new file mode 100644 index 000000000..d18a64367 --- /dev/null +++ b/static/img/bootstrap-controls/navbar-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/navbar-light.svg b/static/img/bootstrap-controls/navbar-light.svg new file mode 100644 index 000000000..7dc3ddfb3 --- /dev/null +++ b/static/img/bootstrap-controls/navbar-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/radio-checked.svg b/static/img/bootstrap-controls/radio-checked.svg new file mode 100644 index 000000000..59849bd2a --- /dev/null +++ b/static/img/bootstrap-controls/radio-checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/select-dark.svg b/static/img/bootstrap-controls/select-dark.svg new file mode 100644 index 000000000..e1f5fbece --- /dev/null +++ b/static/img/bootstrap-controls/select-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/select.svg b/static/img/bootstrap-controls/select.svg new file mode 100644 index 000000000..4107eebb4 --- /dev/null +++ b/static/img/bootstrap-controls/select.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/switch-checked.svg b/static/img/bootstrap-controls/switch-checked.svg new file mode 100644 index 000000000..cccc6235d --- /dev/null +++ b/static/img/bootstrap-controls/switch-checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/switch-dark.svg b/static/img/bootstrap-controls/switch-dark.svg new file mode 100644 index 000000000..55217871e --- /dev/null +++ b/static/img/bootstrap-controls/switch-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/switch-focus.svg b/static/img/bootstrap-controls/switch-focus.svg new file mode 100644 index 000000000..7a7720556 --- /dev/null +++ b/static/img/bootstrap-controls/switch-focus.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/switch.svg b/static/img/bootstrap-controls/switch.svg new file mode 100644 index 000000000..29ed779d0 --- /dev/null +++ b/static/img/bootstrap-controls/switch.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/bootstrap-controls/validation-invalid.svg b/static/img/bootstrap-controls/validation-invalid.svg new file mode 100644 index 000000000..e55dd71a5 --- /dev/null +++ b/static/img/bootstrap-controls/validation-invalid.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/img/bootstrap-controls/validation-valid.svg b/static/img/bootstrap-controls/validation-valid.svg new file mode 100644 index 000000000..fae2d7d60 --- /dev/null +++ b/static/img/bootstrap-controls/validation-valid.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/social/hugegraph-default.png b/static/img/social/hugegraph-default.png new file mode 100644 index 000000000..4e3e3a0c1 Binary files /dev/null and b/static/img/social/hugegraph-default.png differ diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js new file mode 100644 index 000000000..88bd2ed0d --- /dev/null +++ b/tests/e2e/accessibility.spec.js @@ -0,0 +1,24 @@ +const { test, expect } = require("./artifact-test"); +const AxeBuilder = require("@axe-core/playwright").default; + +for (const route of ["/docs/", "/cn/docs/", "/community/", "/cn/community/"]) { + test(`axe WCAG 2.2 AA guard ${route}`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(route); + await page.addStyleTag({ + content: "*,*::before,*::after{animation:none!important;transition:none!important}" + }); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(250); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]) + .analyze(); + const knownOinkBaseline = new Set(["list", "target-size"]); + const blocking = results.violations.filter( + (item) => + ["critical", "serious"].includes(item.impact) && + !knownOinkBaseline.has(item.id) + ); + expect(blocking).toEqual([]); + }); +} diff --git a/tests/e2e/ai-enabled.yaml b/tests/e2e/ai-enabled.yaml new file mode 100644 index 000000000..a0a12d188 --- /dev/null +++ b/tests/e2e/ai-enabled.yaml @@ -0,0 +1,8 @@ +params: + ai_search: + enabled: true + provider: kapa + website_id: 0b277570-4740-451e-96fa-1e4ac1ac5e88 + source_groups: + en: e2e-source-en + cn: e2e-source-cn diff --git a/tests/e2e/ai.spec.js b/tests/e2e/ai.spec.js new file mode 100644 index 000000000..56209630f --- /dev/null +++ b/tests/e2e/ai.spec.js @@ -0,0 +1,133 @@ +const { test, expect } = require("./artifact-test"); + +const AI_ORIGIN = "http://127.0.0.1:4174"; +const mockBundle = ` +(function () { + var queued = window.Kapa && window.Kapa.q ? window.Kapa.q.slice() : []; + window.__kapaCalls = window.__kapaCalls || []; + window.Kapa = function (method, value) { + window.__kapaCalls.push([method, value]); + if (method === 'render' && value && value.onRender) value.onRender(); + }; + queued.forEach(function (args) { window.Kapa.apply(null, Array.from(args)); }); +})();`; + +test.beforeEach(async ({}, testInfo) => { + testInfo.skip(!process.env.AI_SITE_ROOT, "AI-enabled fixture was not built"); +}); + +for (const [locale, route, source, language] of [ + ["en", "/docs/", "e2e-source-en", "en"], + ["cn", "/cn/docs/", "e2e-source-cn", "zh"] +]) { + test(`AI tail is click-gated and locale-bound for ${locale}`, async ({ page }) => { + const requests = []; + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { + requests.push(route.request().url()); + await route.fulfill({ status: 200, contentType: "text/javascript", body: mockBundle }); + }); + await page.goto(AI_ORIGIN + route); + expect(requests).toEqual([]); + await page.evaluate(() => + document.documentElement.setAttribute("data-bs-theme", "dark") + ); + const launcher = page.locator(".hg-ask-ai-launcher"); + await expect(launcher).toBeVisible(); + + await page.locator("[data-td-shell-search-open]").first().click(); + const input = page.locator(".td-shell-search__input"); + await input.fill(" server auth "); + const tail = page.locator("[data-hg-ai-search-tail]"); + await expect(tail).toBeVisible(); + await expect(tail.locator("[data-hg-ask-ai]")).toHaveAttribute( + "data-hg-ai-query", "server auth" + ); + await input.fill(""); + await expect(tail).toHaveCount(0); + await input.fill(">theme"); + await expect(tail).toHaveCount(0); + await input.fill("server auth"); + await tail.locator("[data-hg-ask-ai]").click(); + await expect.poll(() => requests.length).toBe(1); + await expect.poll(() => page.evaluate(() => window.__kapaCalls || [])).toContainEqual([ + "setSourceGroupIDs", [source] + ]); + const script = page.locator("script[data-hg-kapa-widget]"); + await expect(script).toHaveAttribute("data-language", language); + await expect(script).toHaveAttribute("data-source-group-ids-include", source); + await expect(script).toHaveAttribute("data-project-color", "#532fc9"); + await expect(script).toHaveAttribute("data-project-color-dark", "#a693e3"); + await expect(script).toHaveAttribute("data-anchor-color-dark", "#baace9"); + await expect(script).toHaveAttribute( + "data-color-scheme-selector", "[data-bs-theme='dark']" + ); + const calls = await page.evaluate(() => window.__kapaCalls); + expect(calls).toContainEqual([ + "open", { mode: "ai", query: "server auth", submit: true } + ]); + }); +} + +test("AI 500 remains non-blocking and retry issues one fresh request", async ({ page }) => { + let attempts = 0; + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { + attempts += 1; + if (attempts === 1) await route.fulfill({ status: 500, body: "failed" }); + else await route.fulfill({ status: 200, contentType: "text/javascript", body: mockBundle }); + }); + await page.goto(AI_ORIGIN + "/docs/"); + const launcher = page.locator(".hg-ask-ai-launcher"); + await launcher.dblclick(); + await expect.poll(() => attempts).toBe(1); + await expect(launcher).toHaveAttribute("data-hg-ai-state", "error"); + await expect(launcher).toHaveAttribute("title", /unavailable/i); + await launcher.click(); + await expect.poll(() => attempts).toBe(2); + await expect(launcher).toHaveAttribute("data-hg-ai-state", "ready"); + await expect(launcher).not.toHaveAttribute("title", /unavailable/i); +}); + +test("AI pending timeout discards stale state and retry waits for a fresh bundle", async ({ + page +}) => { + let attempts = 0; + let releaseStale; + const staleGate = new Promise((resolve) => { releaseStale = resolve; }); + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { + attempts += 1; + if (attempts === 1) { + await staleGate; + } + await route.fulfill({ + status: 200, + contentType: "text/javascript", + body: mockBundle + }); + }); + await page.goto(AI_ORIGIN + "/docs/"); + const launcher = page.locator(".hg-ask-ai-launcher"); + await launcher.click(); + await expect.poll(() => attempts).toBe(1); + await expect(launcher).toHaveAttribute("data-hg-ai-state", "error", { + timeout: 7_000 + }); + await launcher.click(); + await expect.poll(() => attempts).toBe(2); + await expect(launcher).toHaveAttribute("data-hg-ai-state", "ready"); + expect( + await page.locator("script[data-hg-kapa-widget]").getAttribute("src") + ).toContain("?hg-retry=2"); + expect( + await page.evaluate(() => + (window.__kapaCalls || []).filter(([method]) => method === "open").length + ) + ).toBe(1); + + releaseStale(); + await page.waitForTimeout(250); + expect( + await page.evaluate(() => + (window.__kapaCalls || []).filter(([method]) => method === "open").length + ) + ).toBe(1); +}); diff --git a/tests/e2e/artifact-test.js b/tests/e2e/artifact-test.js new file mode 100644 index 000000000..564a26f86 --- /dev/null +++ b/tests/e2e/artifact-test.js @@ -0,0 +1,31 @@ +const base = require("@playwright/test"); + +const LOCAL_ARTIFACT_ORIGIN = "http://127.0.0.1:4173"; +const PUBLISHED_ORIGINS = new Set([ + "https://hugegraph.apache.org", + "https://hugegraph-oink.staged.apache.org" +]); + +const test = base.test.extend({ + page: async ({ page }, use) => { + await page.route("**/*", async (route) => { + const requested = new URL(route.request().url()); + if (!PUBLISHED_ORIGINS.has(requested.origin)) { + await route.continue(); + return; + } + const local = new URL(requested.pathname + requested.search, LOCAL_ARTIFACT_ORIGIN); + const response = await route.fetch({ url: local.href }); + await route.fulfill({ + response, + headers: { + ...response.headers(), + "access-control-allow-origin": "*" + } + }); + }); + await use(page); + } +}); + +module.exports = { test, expect: base.expect }; diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 000000000..569ab0709 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,103 @@ +{ + "name": "hugegraph-doc-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hugegraph-doc-e2e", + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@playwright/test": "1.62.1" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000..36ef845b8 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "hugegraph-doc-e2e", + "private": true, + "engines": { + "node": ">=24" + }, + "scripts": { + "test": "playwright test", + "test:ci": "node --test ../ui-ai/*.test.cjs && node --test workflow-contract.test.cjs && playwright test versioning.spec.js search-ranking.spec.js platform.spec.js ai.spec.js accessibility.spec.js --reporter=line,html", + "test:visual": "playwright test visual.spec.js --reporter=line" + }, + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@playwright/test": "1.62.1" + } +} diff --git a/tests/e2e/platform.spec.js b/tests/e2e/platform.spec.js new file mode 100644 index 000000000..450746570 --- /dev/null +++ b/tests/e2e/platform.spec.js @@ -0,0 +1,130 @@ +const { test, expect } = require("./artifact-test"); + +for (const locale of ["en", "cn"]) { + const prefix = locale === "cn" ? "/cn" : ""; + test(`latest ${locale} sidebar persists and isolates collapse`, async ({ page }) => { + await page.goto(`${prefix}/docs/introduction/`); + const key = `oink.sidebar.v1.latest.${locale}`; + await expect.poll(() => page.evaluate((name) => localStorage.getItem(name), key)) + .not.toBeNull(); + const toggle = page + .locator('#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navdevelop-children"]') + .first(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await toggle.click(); + const target = await toggle.getAttribute("aria-controls"); + await expect.poll(() => page.evaluate((name) => localStorage.getItem(name), key)) + .toContain(target); + await page.reload(); + await expect(page.locator(`[aria-controls="${target}"]`)).toHaveAttribute( + "aria-expanded", "true" + ); + + await page.locator(".td-shell-sidebar__collapse").click(); + await expect(page.locator("#td-shell-sidebar")).toHaveAttribute("aria-hidden", "true"); + await expect(page.locator("#td-shell-sidebar")).toHaveJSProperty("inert", true); + const restore = page.locator(".hg-sidebar-restore"); + await expect(restore).toBeVisible(); + await restore.click(); + await expect(page.locator("#td-shell-sidebar")).not.toHaveAttribute( + "aria-hidden", "true" + ); + }); + + test(`latest ${locale} mobile drawer restores focus and unlocks scroll`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${prefix}/docs/`); + const opener = page.locator("[data-td-shell-drawer-open]"); + await opener.click(); + await expect(page.locator("html")).toHaveAttribute("data-td-shell-drawer", "open"); + await page.locator("button[data-td-shell-drawer-close]").click(); + await expect(page.locator("#td-shell-sidebar")).toHaveJSProperty("inert", true); + await expect(opener).toBeFocused(); + await expect(page.locator("html")).not.toHaveAttribute("data-td-shell-lock", ""); + }); +} + +test("disabled AI emits no UI or Kapa request", async ({ page }) => { + const kapaRequests = []; + page.on("request", (request) => { + if (request.url().includes("kapa.ai")) kapaRequests.push(request.url()); + }); + await page.goto("/docs/"); + await page.locator("[data-td-shell-search-open]").first().click(); + await page.locator(".td-shell-search__input").fill("server"); + await expect(page.locator('[role="option"]').first()).toBeVisible(); + expect(kapaRequests).toEqual([]); + await expect(page.locator("[data-hg-ask-ai]")).toHaveCount(0); +}); + +test("search index failure keeps one stable, focusable retry control", async ({ + page +}) => { + let attempts = 0; + await page.route("**/offline-search-index.en.*.json", async (route) => { + attempts += 1; + if (attempts === 1) await route.abort("failed"); + else await route.fallback(); + }); + await page.goto("/docs/"); + await page.locator("[data-td-shell-search-open]").first().click(); + await page.locator(".td-shell-search__input").fill("server"); + const retry = page.locator("[data-hg-search-retry]"); + await expect(retry).toHaveCount(1); + const mutations = await retry.evaluate((node) => { + window.__hgRetryNode = node; + window.__hgRetryMutations = 0; + new MutationObserver(() => { window.__hgRetryMutations += 1; }) + .observe(node.parentNode, { childList: true, subtree: true }); + return window.__hgRetryMutations; + }); + expect(mutations).toBe(0); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.__hgRetryMutations)).toBe(0); + expect( + await retry.evaluate((node) => node === window.__hgRetryNode) + ).toBe(true); + + const button = retry.locator("button"); + await button.focus(); + await expect(button).toBeFocused(); + await button.click(); + await expect.poll(() => attempts).toBe(2); + await expect(page.locator('[role="option"]').first()).toBeVisible(); + await expect(page.locator(".td-shell-search__input")).toBeFocused(); + await expect(retry).toHaveCount(0); +}); + +test("Community grid and HTML/Print/Markdown profiles stay in parity", async ({ + page, + request +}) => { + await page.goto("/community/"); + test.skip( + (await page.locator(".hg-community-members__grid").count()) === 0, + "PR-B Community section is not integrated in this artifact" + ); + for (const [width, columns] of [[1440, 5], [900, 3], [390, 2], [320, 2]]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/community/"); + const grid = page.locator(".hg-community-members__grid").first(); + await expect(grid).toBeVisible(); + expect( + await grid.evaluate((node) => getComputedStyle(node).gridTemplateColumns.split(" ").length) + ).toBe(columns); + } + await page.evaluate(() => localStorage.setItem("td-color-theme", "dark")); + await page.reload(); + await expect(page.locator(".hg-community-member__link").first()).toBeVisible(); + await expect(page.locator(".hg-community-member__initials").first()).toBeAttached(); + + const htmlProfiles = await page + .locator("#project-members .hg-community-member__link") + .evaluateAll((links) => links.map((link) => link.href).sort()); + const print = await (await request.get("/_print/community/")).text(); + const markdown = await (await request.get("/community/index.md")).text(); + for (const profile of htmlProfiles) { + expect(print).toContain(profile); + expect(markdown).toContain(profile); + } +}); diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 000000000..7eef0aceb --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,44 @@ +const { defineConfig } = require("@playwright/test"); + +const siteRoot = process.env.SITE_ROOT; +const aiSiteRoot = process.env.AI_SITE_ROOT; +if (!siteRoot) { + throw new Error("SITE_ROOT must point to an aggregate site artifact"); +} + +module.exports = defineConfig({ + testDir: ".", + testMatch: "*.spec.js", + outputDir: "test-results", + timeout: 30_000, + expect: { timeout: 5_000 }, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [["line"], ["html", { outputFolder: "playwright-report", open: "never" }]], + use: { + baseURL: "http://127.0.0.1:4173", + browserName: "chromium", + trace: "retain-on-failure", + screenshot: "only-on-failure" + }, + webServer: [ + { + command: `python3 -m http.server 4173 --bind 127.0.0.1 --directory ${JSON.stringify(siteRoot)}`, + url: "http://127.0.0.1:4173/", + reuseExistingServer: false, + timeout: 30_000, + stdout: "ignore", + stderr: "ignore" + }, + ...(aiSiteRoot + ? [{ + command: `python3 -m http.server 4174 --bind 127.0.0.1 --directory ${JSON.stringify(aiSiteRoot)}`, + url: "http://127.0.0.1:4174/", + reuseExistingServer: false, + timeout: 30_000, + stdout: "ignore", + stderr: "ignore" + }] + : []) + ] +}); diff --git a/tests/e2e/search-ranking.spec.js b/tests/e2e/search-ranking.spec.js new file mode 100644 index 000000000..06f79a4a6 --- /dev/null +++ b/tests/e2e/search-ranking.spec.js @@ -0,0 +1,103 @@ +const { test, expect } = require("./artifact-test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const FALLBACK_CASES = { + en: [ + ["introduction", "/docs/introduction/"], + ["server", "/docs/quickstart/hugegraph/hugegraph-server/"], + ["hstore", "/docs/quickstart/hugegraph/hugegraph-hstore/"], + ["placement driver", "/docs/quickstart/hugegraph/hugegraph-pd/"], + ["computer", "/docs/quickstart/computing/hugegraph-computer/"], + ["loader", "/docs/quickstart/toolchain/hugegraph-loader/"], + ["hubble", "/docs/quickstart/toolchain/hugegraph-hubble/"], + ["clients", "/docs/clients/"], + ["rest api", "/docs/clients/restful-api/"], + ["configuration", "/docs/config/"], + ["authentication", "/docs/config/config-authentication/"], + ["download", "/docs/download/download/"] + ], + cn: [ + ["介绍", "/cn/docs/introduction/"], + ["服务端", "/cn/docs/quickstart/hugegraph/hugegraph-server/"], + ["HStore", "/cn/docs/quickstart/hugegraph/hugegraph-hstore/"], + ["PD", "/cn/docs/quickstart/hugegraph/hugegraph-pd/"], + ["图计算", "/cn/docs/quickstart/computing/hugegraph-computer/"], + ["数据导入", "/cn/docs/quickstart/toolchain/hugegraph-loader/"], + ["图形化界面", "/cn/docs/quickstart/toolchain/hugegraph-hubble/"], + ["客户端", "/cn/docs/clients/"], + ["REST API", "/cn/docs/clients/restful-api/"], + ["配置", "/cn/docs/config/"], + ["认证", "/cn/docs/config/config-authentication/"], + ["下载", "/cn/docs/download/download/"] + ] +}; + +const metadataFixture = path.resolve(__dirname, "../../scripts/fixtures/community_search_queries.json"); +const metadataIntegrated = fs.existsSync(metadataFixture); +const siteRoot = process.env.SITE_ROOT; +const rawCases = metadataIntegrated + ? Object.groupBy( + JSON.parse(fs.readFileSync(metadataFixture, "utf8")).map((entry) => [ + entry.query, + entry.expected_ref + ]), + ([query, expectedRef]) => (expectedRef.startsWith("/cn/") ? "cn" : "en") + ) + : FALLBACK_CASES; +const cases = Object.fromEntries( + Object.entries(rawCases).map(([locale, localeCases]) => { + const indexName = fs + .readdirSync(siteRoot) + .find((name) => name.startsWith(`offline-search-index.${locale}.`) && name.endsWith(".json")); + if (!indexName) throw new Error(`missing ${locale} offline search index`); + const records = JSON.parse(fs.readFileSync(path.join(siteRoot, indexName), "utf8")); + const titles = new Map(records.map((record) => [record.ref, record.title])); + return [ + locale, + localeCases.map(([query, expectedRef]) => { + const expectedTitle = titles.get(expectedRef); + if (!expectedTitle) throw new Error(`missing search record ${expectedRef}`); + return [query, expectedRef, expectedTitle]; + }) + ]; + }) +); + +for (const [locale, localeCases] of Object.entries(cases)) { + test(`summary Lunr ranks fixed ${locale} entry queries`, async ({ page }) => { + test.skip(!metadataIntegrated, "PR-B search metadata fixture is not integrated"); + for (const [query, expectedRef, expectedTitle] of localeCases) { + await page.goto(locale === "cn" ? "/cn/docs/" : "/docs/"); + await page.locator("[data-td-shell-search-open]").first().click(); + const input = page.locator(".td-shell-search__input"); + await input.fill(query); + const pageResults = page + .locator("#td-shell-search-results .td-shell-search__group") + .first() + .locator('[role="option"]'); + await expect(pageResults.first(), `no Lunr results for ${query}`).toBeVisible(); + await expect + .poll(() => + pageResults.evaluateAll((rows) => + rows + .slice(0, 3) + .map((row) => row.querySelector(".td-shell-search__item-title")?.textContent.trim()) + ) + ) + .toContain(expectedTitle); + const titles = await pageResults.evaluateAll((rows) => + rows.slice(0, 3).map((row) => + row.querySelector(".td-shell-search__item-title")?.textContent.trim() + ) + ); + expect( + titles.includes(expectedTitle), + `${query} must rank ${expectedRef} in the top three` + ).toBe(true); + const target = pageResults.filter({ hasText: expectedTitle }).first(); + await target.click(); + await expect(page).toHaveURL((url) => url.pathname === expectedRef); + } + }); +} diff --git a/tests/e2e/versioning.spec.js b/tests/e2e/versioning.spec.js new file mode 100644 index 000000000..413fc2318 --- /dev/null +++ b/tests/e2e/versioning.spec.js @@ -0,0 +1,303 @@ +const { test, expect } = require("./artifact-test"); + +const VERSION_IDS = ["latest", "1.7", "1.5", "1.3", "1.0"]; +const EXPECTED_IDS = (process.env.EXPECTED_VERSIONS || VERSION_IDS.join(",")).split(","); + +async function actionManifest(page) { + return page.locator("#td-action-manifest").evaluate((node) => JSON.parse(node.textContent)); +} + +async function versionOption(page, id) { + const manifest = await actionManifest(page); + return manifest.actions + .find((item) => item.id === "switch_version") + .options.find((item) => item.id === id); +} + +test("aggregate records the immutable five-version manifest", async ({ request }) => { + const response = await request.get("/build-metadata/versions.json"); + expect(response.ok()).toBeTruthy(); + const manifest = await response.json(); + expect(manifest.schemaVersion).toBe(1); + expect(manifest.versions.map((entry) => entry.id)).toEqual(EXPECTED_IDS); + for (const entry of manifest.versions) { + expect(entry.sha).toMatch(/^[0-9a-f]{40}$/); + } +}); + +for (const locale of ["en", "cn"]) { + test(`latest ${locale} exposes the fixed version order`, async ({ page }) => { + await page.goto(locale === "cn" ? "/cn/docs/" : "/docs/"); + const manifest = await actionManifest(page); + const action = manifest.actions.find((item) => item.id === "switch_version"); + expect(action.options.map((item) => item.id)).toEqual(VERSION_IDS); + }); +} + +for (const version of VERSION_IDS.slice(1)) { + for (const locale of ["en", "cn"]) { + test(`${version} ${locale} is archived and directly reachable`, async ({ page }) => { + test.skip(!EXPECTED_IDS.includes(version), "latest-only staging artifact"); + const prefix = `/versions/${version}${locale === "cn" ? "/cn" : ""}`; + await page.goto(`${prefix}/docs/`); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + "content", + /noindex\s*,?\s*follow/i + ); + await expect(page.locator(".td-page-notice--primary")).toBeVisible(); + const manifest = await actionManifest(page); + const action = manifest.actions.find((item) => item.id === "switch_version"); + expect(action.options.map((item) => item.id)).toEqual(VERSION_IDS); + }); + } +} + +test("1.0 flat Server URL remains a static alias", async ({ request }) => { + test.skip(!EXPECTED_IDS.includes("1.0"), "latest-only staging artifact"); + const response = await request.get( + "/versions/1.0/docs/quickstart/hugegraph-server/" + ); + expect(response.ok()).toBeTruthy(); + const body = await response.text(); + expect(body).toMatch( + /http-equiv="refresh"[^>]+quickstart\/hugegraph\/hugegraph-server/ + ); + expect(body).toMatch( + /rel="canonical"[^>]+versions\/1\.0\/docs\/quickstart\/hugegraph\/hugegraph-server\// + ); +}); + +test("desktop and mobile selectors preserve query and hash for an equivalent page", async ({ + page +}) => { + test.skip(!EXPECTED_IDS.includes("1.7"), "latest-only staging artifact"); + await page.goto("/docs/quickstart/hugegraph/?query=server#server"); + const option = await versionOption(page, "1.7"); + expect(option.equivalent).toBe(true); + expect(option.fallback).toBe(false); + + const desktop = page.locator( + ".td-nav-version-menu a[data-hg-version-id='1.7']" + ); + const href = await desktop.getAttribute("href"); + expect(new URL(href).search).toBe("?query=server"); + expect(new URL(href).hash).toBe("#server"); + expect(new URL(href).pathname).toBe(new URL(option.url).pathname); + await page.locator(".td-nav-version-menu [data-td-nav-hover-trigger]").hover(); + await expect(desktop).toBeVisible(); + await desktop.click(); + await expect(page).toHaveURL((url) => + url.pathname === new URL(option.url).pathname && + url.search === "?query=server" && + url.hash === "#server" + ); + + await page.goto("/docs/quickstart/hugegraph/?query=server#server"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator("[data-td-shell-drawer-open]").click(); + const drawer = page.locator( + "#td-shell-sidebar a[data-hg-version-id='1.7']" + ); + await expect(drawer).toHaveAttribute("href", href); + await drawer.click(); + await expect(page).toHaveURL((url) => + url.pathname === new URL(option.url).pathname && + url.search === "?query=server" && + url.hash === "#server" + ); +}); + +test("Palette version choice uses the same equivalent target", async ({ page }) => { + test.skip(!EXPECTED_IDS.includes("1.7"), "latest-only staging artifact"); + await page.goto("/docs/quickstart/hugegraph/?query=server#server"); + const option = await versionOption(page, "1.7"); + await page.locator("[data-td-shell-search-open]").first().click(); + const input = page.locator(".td-shell-search__input"); + await input.fill("Releases"); + await page + .locator('[role="option"]') + .filter({ hasText: "Releases" }) + .first() + .click(); + await page + .locator('[role="option"]') + .filter({ hasText: /^1\.7$/ }) + .click(); + await expect(page).toHaveURL((url) => + url.pathname === new URL(option.url).pathname && + url.search === "?query=server" && + url.hash === "#server" + ); +}); + +test("historical selectors preserve the readme route across desktop, mobile, and Palette", async ({ + page +}) => { + test.skip( + !["1.7", "1.5", "1.3"].every((version) => EXPECTED_IDS.includes(version)), + "latest-only staging artifact" + ); + + await page.goto( + "/versions/1.7/docs/introduction/readme/?query=history#overview" + ); + let option = await versionOption(page, "1.5"); + expect(option.equivalent).toBe(true); + expect(option.fallback).toBe(false); + expect(new URL(option.url).pathname).toBe( + "/versions/1.5/docs/introduction/readme/" + ); + const desktop = page.locator( + ".td-nav-version-menu a[data-hg-version-id='1.5']" + ); + await page.locator(".td-nav-version-menu [data-td-nav-hover-trigger]").hover(); + await desktop.click(); + await expect(page).toHaveURL((url) => + url.pathname === "/versions/1.5/docs/introduction/readme/" && + url.search === "?query=history" && + url.hash === "#overview" + ); + + await page.goto( + "/versions/1.7/cn/docs/introduction/readme/?query=history#overview" + ); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator("[data-td-shell-drawer-open]").click(); + const mobile = page.locator( + "#td-shell-sidebar a[data-hg-version-id='1.3']" + ); + await mobile.click(); + await expect(page).toHaveURL((url) => + url.pathname === "/versions/1.3/cn/docs/introduction/readme/" && + url.search === "?query=history" && + url.hash === "#overview" + ); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto( + "/versions/1.3/docs/introduction/readme/?query=history#overview" + ); + option = await versionOption(page, "1.5"); + expect(new URL(option.url).pathname).toBe( + "/versions/1.5/docs/introduction/readme/" + ); + await page.locator("[data-td-shell-search-open]").first().click(); + const input = page.locator(".td-shell-search__input"); + await input.fill("Releases"); + await page + .locator('[role="option"]') + .filter({ hasText: "Releases" }) + .first() + .click(); + await page + .locator('[role="option"]') + .filter({ hasText: /^1\.5$/ }) + .click(); + await expect(page).toHaveURL((url) => + url.pathname === "/versions/1.5/docs/introduction/readme/" && + url.search === "?query=history" && + url.hash === "#overview" + ); +}); + +test("introduction aliases resolve bidirectionally without merging canonical pages", async ({ + page +}) => { + test.skip( + !["1.7", "1.5", "1.3", "1.0"].every((version) => + EXPECTED_IDS.includes(version) + ), + "latest-only staging artifact" + ); + + await page.goto("/docs/introduction/?query=history#overview"); + let option = await versionOption(page, "1.5"); + expect(option.equivalent).toBe(true); + expect(option.fallback).toBe(false); + expect(new URL(option.url).pathname).toBe( + "/versions/1.5/docs/introduction/readme/" + ); + await page.locator(".td-nav-version-menu [data-td-nav-hover-trigger]").hover(); + await page + .locator(".td-nav-version-menu a[data-hg-version-id='1.5']") + .click(); + await expect(page).toHaveURL((url) => + url.pathname === "/versions/1.5/docs/introduction/readme/" && + url.search === "?query=history" && + url.hash === "#overview" + ); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/cn/docs/introduction/?query=history#overview"); + await page.locator("[data-td-shell-drawer-open]").click(); + await page + .locator("#td-shell-sidebar a[data-hg-version-id='1.3']") + .click(); + await expect(page).toHaveURL((url) => + url.pathname === "/versions/1.3/cn/docs/introduction/readme/" && + url.search === "?query=history" && + url.hash === "#overview" + ); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto( + "/versions/1.7/docs/introduction/readme/?query=history#overview" + ); + option = await versionOption(page, "latest"); + expect(new URL(option.url).pathname).toBe("/docs/introduction/"); + await page.locator("[data-td-shell-search-open]").first().click(); + await page.locator(".td-shell-search__input").fill("Releases"); + await page + .locator('[role="option"]') + .filter({ hasText: "Releases" }) + .first() + .click(); + await page + .locator('[role="option"]') + .filter({ hasText: /^latest$/ }) + .click(); + await expect(page).toHaveURL((url) => + url.pathname === "/docs/introduction/" && + url.search === "?query=history" && + url.hash === "#overview" + ); + + for (const locale of ["en", "cn"]) { + const prefix = locale === "cn" ? "/cn" : ""; + await page.goto(`/versions/1.7${prefix}/docs/introduction/`); + option = await versionOption(page, "latest"); + expect(new URL(option.url).pathname).toBe( + `${prefix}/docs/introduction/` + ); + expect(option.equivalent).toBe(true); + expect(option.fallback).toBe(false); + } +}); + +for (const locale of ["en", "cn"]) { + test(`${locale} missing page falls back to its docs root once`, async ({ page }) => { + test.skip(!EXPECTED_IDS.includes("1.0"), "latest-only staging artifact"); + const prefix = locale === "cn" ? "/cn" : ""; + await page.goto(`${prefix}/docs/guides/security/?query=discard#discard`); + const option = await versionOption(page, "1.0"); + expect(option.fallback).toBe(true); + expect(option.equivalent).toBe(false); + expect(new URL(option.url).pathname).toBe(`/versions/1.0${prefix}/docs/`); + expect(new URL(option.url).search).toBe(""); + expect(new URL(option.url).hash).toBe("#hg-version-fallback"); + + await page.evaluate((target) => { + window.OinkActions.run("switch_version", { value: target }); + }, option); + await page.waitForURL((url) => url.pathname === `/versions/1.0${prefix}/docs/`); + await expect( + page.locator("[data-hg-version-fallback-notice]") + ).toHaveCount(1); + expect(new URL(page.url()).search).toBe(""); + expect(new URL(page.url()).hash).toBe(""); + await page.reload(); + await expect( + page.locator("[data-hg-version-fallback-notice]") + ).toHaveCount(0); + }); +} diff --git a/tests/e2e/visual.spec.js b/tests/e2e/visual.spec.js new file mode 100644 index 000000000..a2d4534ac --- /dev/null +++ b/tests/e2e/visual.spec.js @@ -0,0 +1,43 @@ +const { test, expect } = require("./artifact-test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const states = [ + ["en-docs-desktop-light", "/docs/", { width: 1440, height: 900 }, "light", "default"], + ["cn-docs-desktop-dark", "/cn/docs/", { width: 1440, height: 900 }, "dark", "default"], + ["en-search-desktop-light", "/docs/", { width: 1440, height: 900 }, "light", "search"], + ["cn-search-mobile-dark", "/cn/docs/", { width: 390, height: 844 }, "dark", "search"], + ["en-sidebar-desktop-light", "/docs/introduction/", { width: 1440, height: 900 }, "light", "collapse"], + ["cn-sidebar-mobile-dark", "/cn/docs/", { width: 390, height: 844 }, "dark", "drawer"], + ["en-community-desktop-light", "/community/", { width: 1440, height: 900 }, "light", "default"], + ["cn-community-mobile-dark", "/cn/community/", { width: 320, height: 720 }, "dark", "default"] +]; + +for (const [name, url, viewport, theme, state] of states) { + test(`capture advisory ${name}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.addInitScript( + (value) => localStorage.setItem("td-color-theme", value), + theme + ); + await page.goto(url); + await expect(page.locator("body")).toBeVisible(); + if (state === "search") { + await page.locator("[data-td-shell-search-open]").first().click(); + await page.locator(".td-shell-search__input").fill(url.startsWith("/cn/") ? "服务端" : "server"); + await expect(page.locator('[role="option"]').first()).toBeVisible(); + } else if (state === "collapse") { + await page.locator(".td-shell-sidebar__collapse").click(); + await expect(page.locator("#td-shell-sidebar")).toHaveAttribute("aria-hidden", "true"); + } else if (state === "drawer") { + await page.locator("[data-td-shell-drawer-open]").click(); + await expect(page.locator("html")).toHaveAttribute("data-td-shell-drawer", "open"); + } + const directory = path.join(__dirname, "visual-results"); + fs.mkdirSync(directory, { recursive: true }); + await page.screenshot({ + path: path.join(directory, `${name}.png`), + fullPage: true + }); + }); +} diff --git a/tests/e2e/workflow-contract.test.cjs b/tests/e2e/workflow-contract.test.cjs new file mode 100644 index 000000000..c45cc4d31 --- /dev/null +++ b/tests/e2e/workflow-contract.test.cjs @@ -0,0 +1,88 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const workflow = fs.readFileSync( + path.resolve(__dirname, "../../.github/workflows/hugo.yml"), + "utf8" +); +const versionManifest = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../../versions.json"), "utf8") +); + +test("each build fetches and verifies its immutable matrix SHA", () => { + assert.match(workflow, /RESOLVED_SHA: \$\{\{ matrix\.version\.sha \}\}/); + assert.match(workflow, /git fetch --no-tags origin "\$RESOLVED_SHA"/); + assert.match(workflow, /git cat-file -e "\$RESOLVED_SHA\^\{commit\}"/); +}); + +test("only publish receives write permission and deploy stays read-only", () => { + const writeMatches = workflow.match(/contents: write/g) || []; + assert.equal(writeMatches.length, 1); + assert.match( + workflow, + /deploy:[\s\S]*?permissions: \{ contents: read \}[\s\S]*?publish:/ + ); + assert.match(workflow, /publish:[\s\S]*?permissions: \{ contents: write \}/); +}); + +test("event plan derives runtime selection from versions.json", () => { + assert.match( + workflow, + /all_selection="\$\(jq -er '\[\.versions\[\]\.id\] \| join\(","\)' versions\.json\)"/ + ); + assert.match( + workflow, + /latest_selection="\$\(jq -er '\[\.versions\[\] \| select\(\.archived == false\) \| \.id\] \| join\(","\)' versions\.json\)"/ + ); + assert.doesNotMatch(workflow, /selection="latest,1\.7,1\.5,1\.3,1\.0"/); + assert.match(workflow, /selection="\$all_selection"/); + assert.match(workflow, /selection="\$latest_selection"/); + assert.match(workflow, /test "\$candidate" = "\$latest_ref"/); + assert.deepEqual( + versionManifest.versions.map(({ id }) => id), + ["latest", "1.7", "1.5", "1.3", "1.0"], + "the reviewed manifest still declares the accepted five-version product order" + ); + assert.match(workflow, /test "\$CONFIRMATION" = "publish asf-staging-oink"/); + assert.match(workflow, /test "\$CONFIRMATION" = "publish asf-site"/); + assert.match(workflow, /production:asf-site\|staging:asf-staging-oink/); + assert.doesNotMatch(workflow, /permissions:\s*write-all/); +}); + +test("concurrency serializes every writer to the same ASF target", () => { + assert.match( + workflow, + /group: \$\{\{ github\.workflow \}\}-\$\{\{[\s\S]*inputs\.operation == 'staging-next'[\s\S]*'asf-staging-oink'[\s\S]*'asf-site'[\s\S]*\}\}/ + ); + assert.doesNotMatch( + workflow, + /group:[^\n]*(?:inputs\.operation \|\| 'automatic'|github\.ref)/ + ); +}); + +test("aggregate binds the option-looking artifact suffix", () => { + assert.match( + workflow, + /--artifact-suffix="-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}"/ + ); + assert.doesNotMatch( + workflow, + /--artifact-suffix\s+"-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}"/ + ); +}); + +test("prepare pins Hugo and WebP tools before source validators", () => { + const setupHugo = workflow.indexOf("name: Setup Hugo Extended"); + const setupWebp = workflow.indexOf("name: Install WebP validators"); + const validators = workflow.indexOf("name: Validate source and version tooling"); + assert.ok(setupHugo >= 0 && setupHugo < validators); + assert.ok(setupWebp >= 0 && setupWebp < validators); + assert.match(workflow, /apt-get install --yes --no-install-recommends webp/); + assert.match(workflow, /command -v cwebp[\s\S]*command -v dwebp/); + assert.match( + workflow, + /aggregate[\s\S]*--historical-origin "\$HISTORICAL_ORIGIN"/, + ); +}); diff --git a/tests/ui-ai/ai-enabled.yaml b/tests/ui-ai/ai-enabled.yaml new file mode 100644 index 000000000..4ed54da7e --- /dev/null +++ b/tests/ui-ai/ai-enabled.yaml @@ -0,0 +1,8 @@ +params: + ai_search: + enabled: true + provider: kapa + website_id: test-website-id + source_groups: + en: test-latest-en + cn: test-latest-cn diff --git a/tests/ui-ai/ai-invalid.yaml b/tests/ui-ai/ai-invalid.yaml new file mode 100644 index 000000000..48dc01a27 --- /dev/null +++ b/tests/ui-ai/ai-invalid.yaml @@ -0,0 +1,8 @@ +params: + ai_search: + enabled: true + provider: kapa + website_id: test-website-id + source_groups: + en: test-latest-en + cn: '' diff --git a/tests/ui-ai/backlinks-render.test.cjs b/tests/ui-ai/backlinks-render.test.cjs new file mode 100644 index 000000000..4cf6add55 --- /dev/null +++ b/tests/ui-ai/backlinks-render.test.cjs @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '../..'); + +test('six backlink fixture renders five rows and one expandable row', () => { + const destination = fs.mkdtempSync( + path.join(os.tmpdir(), 'hg-backlinks-fixture-'), + ); + try { + const result = spawnSync( + 'hugo', + [ + '--config', + 'hugo.yaml,tests/ui-ai/fixtures/backlinks.yaml', + '--destination', + destination, + '--quiet', + ], + { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + HUGO_CACHEDIR: path.join(destination, '.hugo-cache'), + }, + }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + const html = fs.readFileSync( + path.join(destination, 'docs/target/index.html'), + 'utf8', + ); + const block = html.match( + /