From a7dad55a26a84d778a77cb6fd0f00d4d8c17d887 Mon Sep 17 00:00:00 2001 From: imbajin Date: Sun, 20 Sep 2026 00:09:06 +0800 Subject: [PATCH 1/3] feat: complete OINK site integration (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Traceability:** closes [#468](https://github.com/apache/hugegraph-doc/issues/468); evidence and design checkpoints are tracked in [#467](https://github.com/apache/hugegraph-doc/issues/467). Full-content search remains deferred in [#471](https://github.com/apache/hugegraph-doc/issues/471); upstream OINK proposals are [#40](https://github.com/pgsty/oink/issues/40) and [#41](https://github.com/pgsty/oink/issues/41). - **Version navigation**: keeps `latest`, `1.7`, and `1.5` visible; older releases are in an accessible `…` menu with stable locale/version routes. - **Responsive shell**: supports explicit collapse, pointer-edge preview, keyboard restore, mobile navigation, dark/light themes, and focus-safe transitions. - **Downloads**: renders ASF release data for EN/CN with mirror, ASC, SHA512, release notes, and keyboard-scrollable mobile tables. - **Community**: adds bilingual member roster and component surfaces with constrained roster data and search metadata. - **Ask AI**: click-gated by a same-origin consent dialog; disabled by default (`ai_search.enabled: false`) pending source-group configuration. - **Search and delivery**: preserves existing HStore keywords and validates the assembled multi-version site, routes, SEO, security, accessibility, and workflow contracts. --- .github/workflows/hugo.yml | 42 +- .gitignore | 3 + assets/js/hugegraph-shell.js | 153 +++- assets/js/kapa-adapter.js | 75 +- assets/scss/_styles_project.scss | 144 ++- assets/scss/community-members.scss | 99 +++ content/cn/docs/_nav/operate.md | 4 +- content/cn/docs/clients/_index.md | 3 + content/cn/docs/clients/restful-api/_index.md | 2 + content/cn/docs/clients/restful-api/vertex.md | 113 +-- .../cn/docs/config/config-authentication.md | 2 + content/cn/docs/config/config-guide.md | 4 +- content/cn/docs/download/download.md | 95 +- content/cn/docs/introduction/_index.md | 4 +- .../computing/hugegraph-computer.md | 2 + .../quickstart/hugegraph/hugegraph-hstore.md | 6 +- .../docs/quickstart/hugegraph/hugegraph-pd.md | 2 + .../quickstart/hugegraph/hugegraph-server.md | 17 +- .../quickstart/toolchain/hugegraph-hubble.md | 3 + .../quickstart/toolchain/hugegraph-loader.md | 2 + content/en/docs/clients/_index.md | 3 + content/en/docs/clients/restful-api/_index.md | 3 + content/en/docs/clients/restful-api/vertex.md | 101 +-- .../en/docs/config/config-authentication.md | 2 + content/en/docs/config/config-guide.md | 4 +- content/en/docs/download/download.md | 90 +- content/en/docs/introduction/_index.md | 4 +- .../computing/hugegraph-computer.md | 2 + .../quickstart/hugegraph/hugegraph-hstore.md | 6 +- .../docs/quickstart/hugegraph/hugegraph-pd.md | 2 + .../quickstart/hugegraph/hugegraph-server.md | 17 +- .../quickstart/toolchain/hugegraph-hubble.md | 3 + .../quickstart/toolchain/hugegraph-loader.md | 2 + data/community/github-map.json | 92 ++ data/community/roster.json | 304 +++++++ data/downloads/asf.json | 53 ++ data/home/cn.yaml | 6 +- data/home/en.yaml | 5 +- data/landing/community/cn.yaml | 9 +- data/landing/community/en.yaml | 9 +- dist/validate-site-output.py | 2 +- hugo.yaml | 2 +- i18n/en.yaml | 22 + i18n/zh-CN.yaml | 21 + layouts/_partials/asf-downloads.html | 104 +++ layouts/_partials/community/members.html | 41 + layouts/_partials/community/members.md | 26 + layouts/_partials/hooks/body-end.html | 16 +- .../landing/sections/community-members.html | 1 + layouts/_partials/navbar.html | 29 +- layouts/_partials/shell/sidebar-panel.html | 10 +- layouts/_partials/version-menu-links.html | 17 + layouts/_shortcodes/asf-downloads.html | 8 + layouts/community/landing.md | 29 + scripts/community_roster.md | 41 + scripts/community_roster.py | 755 ++++++++++++++++ .../fixtures/community_search_queries.json | 26 + scripts/test_community_roster.py | 820 ++++++++++++++++++ scripts/test_download_data.py | 217 +++++ scripts/versioning.py | 2 +- ...0064d012d3de1d57dec883c3e3fa6901d3f85.webp | Bin 0 -> 1170 bytes ...aee806047a84fecaed5829893494dbd35736b.webp | Bin 0 -> 1104 bytes ...8a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp | Bin 0 -> 3224 bytes ...ef2131eaf935f9e8abf69e468cbeb1474ac75.webp | Bin 0 -> 2002 bytes ...7bfb1d1bbee78ea050d920047f3e9874f81f1.webp | Bin 0 -> 3880 bytes ...559f0002ef1b63a2a517fda0cd5a09f4a820d.webp | Bin 0 -> 3654 bytes ...ae28f5f44d874454d197fd5f7a08595f8d7a3.webp | Bin 0 -> 376 bytes ...b361172ebb3dae217a71f79b7531a7637abdc.webp | Bin 0 -> 3274 bytes ...69f481b73cf9d8f1d6d985e865d87e633e36a.webp | Bin 0 -> 1326 bytes ...a61a514377c349d8fed86f091792b303e76e0.webp | Bin 0 -> 1406 bytes ...39a8cedd7c8ab613b49be5e75991f98c78dac.webp | Bin 0 -> 3602 bytes ...782a18a74ed0957ca02c1ac16e1bfa58c899f.webp | Bin 0 -> 2910 bytes ...f37ae54cdc12eac678debcd8989b12f64ee59.webp | Bin 0 -> 2858 bytes ...5b619058c665b2cc67eadac43df2e454b68e6.webp | Bin 0 -> 2662 bytes ...67bc21d05f4a7ed018bc232cdc0c83cbf2346.webp | Bin 0 -> 5264 bytes ...00c5701e4384bb4414126ccfccb9dadacfe1a.webp | Bin 0 -> 1408 bytes ...df651b1459206d2973b8bbb5bd302015d1a14.webp | Bin 0 -> 2412 bytes ...2baadb2b03b284dfb3b6159e792fb9a44e31d.webp | Bin 0 -> 2966 bytes ...9fe405a8846fb2994d700d079b9215786aa17.webp | Bin 0 -> 3952 bytes tests/e2e/accessibility.spec.js | 30 +- tests/e2e/ai.spec.js | 34 + tests/e2e/artifact-test.js | 22 +- tests/e2e/platform.spec.js | 58 +- tests/e2e/versioning.spec.js | 2 + tests/e2e/workflow-contract.test.cjs | 20 + tests/ui-ai/kapa-adapter.test.cjs | 174 ++++ tests/ui-ai/ui-contract.test.cjs | 6 +- 87 files changed, 3611 insertions(+), 416 deletions(-) create mode 100644 assets/scss/community-members.scss create mode 100644 data/community/github-map.json create mode 100644 data/community/roster.json create mode 100644 data/downloads/asf.json create mode 100644 layouts/_partials/asf-downloads.html create mode 100644 layouts/_partials/community/members.html create mode 100644 layouts/_partials/community/members.md create mode 100644 layouts/_partials/landing/sections/community-members.html create mode 100644 layouts/_partials/version-menu-links.html create mode 100644 layouts/_shortcodes/asf-downloads.html create mode 100644 layouts/community/landing.md create mode 100644 scripts/community_roster.md create mode 100644 scripts/community_roster.py create mode 100644 scripts/fixtures/community_search_queries.json create mode 100644 scripts/test_community_roster.py create mode 100644 scripts/test_download_data.py create mode 100644 static/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp create mode 100644 static/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp create mode 100644 static/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp create mode 100644 static/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp create mode 100644 static/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp create mode 100644 static/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp create mode 100644 static/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp create mode 100644 static/img/community/avatars/657d7c6a76e43a96d9da26ab4eab361172ebb3dae217a71f79b7531a7637abdc.webp create mode 100644 static/img/community/avatars/695bd7904d7f4cfab98b7d812c769f481b73cf9d8f1d6d985e865d87e633e36a.webp create mode 100644 static/img/community/avatars/6962957beb238414dd798740aada61a514377c349d8fed86f091792b303e76e0.webp create mode 100644 static/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp create mode 100644 static/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp create mode 100644 static/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp create mode 100644 static/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp create mode 100644 static/img/community/avatars/c936571abaf109fd7049f976c1767bc21d05f4a7ed018bc232cdc0c83cbf2346.webp create mode 100644 static/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp create mode 100644 static/img/community/avatars/d3ecd2b51f116bece9a9c4cd6b6df651b1459206d2973b8bbb5bd302015d1a14.webp create mode 100644 static/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp create mode 100644 static/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index ff1172e3e1..f9c751bcc6 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -51,6 +51,7 @@ jobs: artifact_prefix: ${{ steps.plan.outputs.artifact_prefix }} publish_branch: ${{ steps.plan.outputs.publish_branch }} latest_sha: ${{ steps.plan.outputs.latest_sha }} + source_sha: ${{ steps.plan.outputs.source_sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -103,7 +104,6 @@ jobs: 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._/-]*$ ]] @@ -119,6 +119,9 @@ jobs: case "$OPERATION" in staging-next) + # Dispatches must load this privileged workflow from master; + # the candidate is data resolved to an immutable commit below. + test "$GITHUB_REF" = "refs/heads/master" test "$CONFIRMATION" = "publish asf-staging-oink" site_origin="$STAGING_ORIGIN" artifact_prefix="staging" @@ -131,6 +134,7 @@ jobs: fi ;; production-history-refresh) + test "$GITHUB_REF" = refs/heads/master test "$CONFIRMATION" = "publish asf-site" test "$candidate" = "$latest_ref" test "$SCOPE" = full @@ -152,11 +156,13 @@ jobs: echo "artifact_prefix=$artifact_prefix" echo "publish_branch=$publish_branch" echo "latest_sha=$latest_sha" + echo "source_sha=$latest_sha" } >> "$GITHUB_OUTPUT" - name: Validate source and version tooling run: | bash dist/validate-links.sh + go mod download PYTHONDONTWRITEBYTECODE=1 \ python3 -m unittest discover -s scripts -p 'test_*.py' -v - name: Resolve immutable version matrix @@ -172,10 +178,12 @@ jobs: - name: Upload resolved version manifest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: resolved-versions-${{ github.run_id }}-${{ github.run_attempt }} + # Keep dependency artifact names stable across selective job reruns. + name: resolved-versions-${{ github.run_id }} path: resolved-versions.json retention-days: 7 if-no-files-found: error + overwrite: true build: needs: prepare @@ -193,7 +201,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + ref: ${{ needs.prepare.outputs.source_sha }} fetch-depth: 0 persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -245,11 +253,12 @@ jobs: - name: Upload isolated version artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ needs.prepare.outputs.artifact_prefix }}-${{ matrix.version.id }}-${{ github.run_id }}-${{ github.run_attempt }} + name: ${{ needs.prepare.outputs.artifact_prefix }}-${{ matrix.version.id }}-${{ github.run_id }} path: ${{ runner.temp }}/version-public include-hidden-files: true retention-days: 1 if-no-files-found: error + overwrite: true aggregate: needs: [prepare, build] @@ -259,18 +268,18 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + ref: ${{ needs.prepare.outputs.source_sha }} persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: resolved-versions-${{ github.run_id }}-${{ github.run_attempt }} + name: resolved-versions-${{ github.run_id }} path: resolved - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: ${{ needs.prepare.outputs.artifact_prefix }}-*-${{ github.run_id }}-${{ github.run_attempt }} + pattern: ${{ needs.prepare.outputs.artifact_prefix }}-*-${{ github.run_id }} path: version-artifacts - name: Assemble publishable site env: @@ -285,19 +294,24 @@ jobs: fi python3 scripts/versioning.py aggregate \ --artifacts version-artifacts --artifact-prefix "$PREFIX-" \ - --artifact-suffix="-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --artifact-suffix="-${GITHUB_RUN_ID}" \ --resolved-manifest resolved/resolved-versions.json \ --site-origin "$SITE_ORIGIN" --historical-origin "$HISTORICAL_ORIGIN" \ --select "$SELECTION" \ --output "${RUNNER_TEMP}/public-site" "${extra[@]}" + - name: Verify rendered download contracts + env: + DOWNLOAD_PUBLIC_DIR: ${{ runner.temp }}/public-site + run: python3 -m unittest scripts.test_download_data.DownloadDataTest.test_rendered_download_pages_have_verified_rows -v - name: Upload publishable aggregate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }} + name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }} path: ${{ runner.temp }}/public-site include-hidden-files: true retention-days: ${{ github.event_name == 'pull_request' && 1 || 7 }} if-no-files-found: error + overwrite: true e2e: needs: [prepare, aggregate] @@ -309,7 +323,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + ref: ${{ needs.prepare.outputs.source_sha }} persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -327,7 +341,7 @@ jobs: extended: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }} + name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }} path: public-site - name: Install Chromium test workspace working-directory: tests/e2e @@ -372,7 +386,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} + ref: ${{ needs.prepare.outputs.source_sha }} persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -381,7 +395,7 @@ jobs: 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 }} + name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }} path: public-site - name: Capture advisory visual states working-directory: tests/e2e @@ -429,7 +443,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }}-${{ github.run_attempt }} + name: hugegraph-site-${{ needs.prepare.outputs.artifact_prefix }}-${{ github.run_id }} path: public-site - name: Verify fixed publication target env: diff --git a/.gitignore b/.gitignore index cc2d3a9949..66380f186d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ GEMINI.md .gemini/ WARP.md /.goal-task + +__pycache__/ +*.pyc diff --git a/assets/js/hugegraph-shell.js b/assets/js/hugegraph-shell.js index f5e51ba453..09240eb546 100644 --- a/assets/js/hugegraph-shell.js +++ b/assets/js/hugegraph-shell.js @@ -52,7 +52,7 @@ if (!buttons.length) return; var storage = safeStorage(windowObject); var key = - 'oink.sidebar.v1.' + + 'oink.sidebar.v2.' + String(config.version || 'latest') + '.' + String(config.locale || 'en'); @@ -62,10 +62,13 @@ }), ); var saved = []; + var hasSavedState = false; if (storage) { try { - var parsed = JSON.parse(storage.getItem(key) || '[]'); + var stored = storage.getItem(key); + var parsed = JSON.parse(stored || '[]'); if (Array.isArray(parsed)) { + hasSavedState = stored !== null; saved = parsed.filter(function (id) { return typeof id === 'string' && valid.has(id); }); @@ -75,13 +78,19 @@ } } var remembered = new Set(saved); + var docsRoot = /(?:^|\/)(?:cn\/)?docs\/?$/.test(windowObject.location.pathname); buttons.forEach(function (button) { var item = button.closest('li'); var activePath = item && item.classList.contains('td-active-path'); + var control = button.getAttribute('aria-controls') || ''; + var defaultOpen = + !hasSavedState && + docsRoot && + /_nav(?:start|components)-children$/.test(control); setTreeExpanded( button, - Boolean(activePath || remembered.has(button.getAttribute('aria-controls'))), + Boolean(activePath || remembered.has(control) || defaultOpen), documentObject, ); button.addEventListener('click', function () { @@ -108,11 +117,22 @@ }); }); - // Rewriting the filtered set removes stale node IDs after navigation - // changes without retaining a second schema/version marker. - if (storage) { + // Seed the new persistence schema once so the docs-home defaults survive + // reloads; later clicks replace this set with the user's choices. + if (storage && !hasSavedState) { try { - storage.setItem(key, JSON.stringify(saved)); + var initial = buttons + .filter(function (button) { + var item = button.closest('li'); + return ( + button.getAttribute('aria-expanded') === 'true' && + !(item && item.classList.contains('td-active-path')) + ); + }) + .map(function (button) { + return button.getAttribute('aria-controls'); + }); + storage.setItem(key, JSON.stringify(initial)); } catch (_) { /* Ignore storage becoming unavailable after the probe. */ } @@ -126,12 +146,83 @@ var restore = documentObject.querySelector('.hg-sidebar-restore'); var desktop = windowObject.matchMedia('(min-width: 768px)'); + var panel = sidebar.querySelector('.td-shell-sidebar__panel'); + // The native 16px panel edge cannot receive pointers while the sidebar is + // inert. Keep an equivalent pointer-only strip outside the inert subtree; + // the labelled navbar button remains the keyboard/touch equivalent. + var edge = documentObject.createElement('div'); + edge.className = 'hg-sidebar-edge d-print-none'; + edge.setAttribute('aria-hidden', 'true'); + documentObject.body.appendChild(edge); + var closeTimer; + var pointerLockUntil = 0; + function dynamic() { + return desktop.matches && + html.getAttribute('data-td-shell-sidebar') === 'collapsed'; + } + function preview() { + if (!dynamic()) return; + windowObject.clearTimeout(closeTimer); + sidebar.classList.add('td-shell-sidebar--overlay'); + sync(); + } + function closePreview() { + windowObject.clearTimeout(closeTimer); + closeTimer = windowObject.setTimeout(function () { + if (!sidebar.contains(documentObject.activeElement)) { + sidebar.classList.remove('td-shell-sidebar--overlay'); + sync(); + } + }, 350); + } + edge.addEventListener('pointerenter', function (event) { + if (event.pointerType !== 'touch' && Date.now() >= pointerLockUntil) preview(); + }); + edge.addEventListener('pointerleave', closePreview); + if (restore) { + restore.addEventListener('pointerenter', function (event) { + if (event.pointerType !== 'touch' && Date.now() >= pointerLockUntil) preview(); + }); + restore.addEventListener('pointerleave', closePreview); + restore.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowRight' || !dynamic()) return; + event.preventDefault(); + preview(); + var first = sidebar.querySelector('a[href], button'); + if (first) first.focus(); + }); + } + if (panel) { + panel.addEventListener('pointerenter', function () { + windowObject.clearTimeout(closeTimer); + }); + panel.addEventListener('pointerleave', closePreview); + panel.addEventListener('focusout', closePreview); + panel.addEventListener('keydown', function (event) { + if (event.key !== 'Escape' || !dynamic()) return; + event.preventDefault(); + if (restore) restore.focus(); + sidebar.classList.remove('td-shell-sidebar--overlay'); + sync(); + }); + } + function sync() { var collapsed = html.getAttribute('data-td-shell-sidebar') === 'collapsed'; var drawerOpen = html.getAttribute('data-td-shell-drawer') === 'open'; - var isolated = desktop.matches ? collapsed : !drawerOpen; + // OINK owns the persistent collapsed mode and pointer overlay. Keep + // focus inside an open preview safe when its native pointerleave fires. + if (desktop.matches && collapsed && + !sidebar.classList.contains('td-shell-sidebar--overlay') && + sidebar.contains(documentObject.activeElement)) { + sidebar.classList.add('td-shell-sidebar--overlay'); + } + var overlay = sidebar.classList.contains('td-shell-sidebar--overlay'); + var isolated = desktop.matches ? collapsed && !overlay : !drawerOpen; + edge.hidden = !desktop.matches || !collapsed; + if (restore) restore.setAttribute('aria-expanded', String(!isolated)); if (restore) restore.hidden = !desktop.matches || !collapsed; sidebar.inert = isolated; if (isolated) sidebar.setAttribute('aria-hidden', 'true'); @@ -150,17 +241,58 @@ attributes: true, attributeFilter: ['data-td-shell-sidebar', 'data-td-shell-drawer'], }); + new MutationObserver(sync).observe(sidebar, { attributes: true, attributeFilter: ['class'] }); desktop.addEventListener('change', sync); documentObject .querySelectorAll('[data-td-shell-sidebar-toggle], [data-td-shell-drawer-close]') .forEach(function (button) { button.addEventListener('click', function () { - global.queueMicrotask(sync); - }); + var hadFocus = documentObject.activeElement === button; + // Match OINK's cooldown so a newly visible trigger under the pointer + // does not immediately undo an explicit keyboard collapse. + pointerLockUntil = Date.now() + 150; + // Run after OINK's native click listener regardless of chunk order. + global.setTimeout(function () { + if (dynamic() && sidebar.contains(button) && restore) { + restore.hidden = false; + restore.focus(); + sidebar.classList.remove('td-shell-sidebar--overlay'); + } + sync(); + // Both external restore controls disappear when pinned. Transfer + // their focus after making the sidebar operable; hover and clicks + // that did not focus a trigger must not steal unrelated focus. + if (hadFocus && desktop.matches && !dynamic() && + !sidebar.contains(button) && button.offsetParent === null) { + var collapse = sidebar.querySelector('.td-shell-sidebar__collapse'); + if (collapse) collapse.focus(); + } + }, 0); + }, true); }); sync(); } + function initScrollableTables(documentObject) { + documentObject.querySelectorAll('[data-td-asset-table-scroll]').forEach(function (region) { + if (region.dataset.hgTableScrollBound !== undefined) return; + region.dataset.hgTableScrollBound = ''; + region.addEventListener('keydown', function (event) { + // Keep native keyboard behavior for links and other descendants in the table. + if (event.target !== region) return; + if (region.scrollWidth <= region.clientWidth) return; + var delta = 0; + if (event.key === 'ArrowRight') delta = 80; + else if (event.key === 'ArrowLeft') delta = -80; + else if (event.key === 'Home') delta = -region.scrollLeft; + else if (event.key === 'End') delta = region.scrollWidth - region.clientWidth - region.scrollLeft; + else return; + event.preventDefault(); + region.scrollBy({ left: delta, behavior: 'smooth' }); + }); + }); + } + function initSearchRetry(windowObject, documentObject) { var root = documentObject.getElementById('td-shell-search'); if (!root) return; @@ -324,6 +456,7 @@ initVersionSwitching(windowObject, documentObject); initTreePersistence(windowObject, documentObject, config); initSidebarIsolation(windowObject, documentObject); + initScrollableTables(documentObject); initSearchRetry(windowObject, documentObject); } diff --git a/assets/js/kapa-adapter.js b/assets/js/kapa-adapter.js index e964ec2465..b5d1913437 100644 --- a/assets/js/kapa-adapter.js +++ b/assets/js/kapa-adapter.js @@ -107,6 +107,9 @@ function createController(windowObject, documentObject, config) { var state = 'idle'; + var consented = false; + var pending = null; + var consent = documentObject.querySelector('[data-hg-ai-consent]'); var attempt = 0; var timer = 0; var lastTrigger = null; @@ -220,9 +223,8 @@ documentObject.head.appendChild(script); } - function activate(query, submit, trigger) { + function load(query, submit) { query = trimmedQuery(query); - lastTrigger = trigger || documentObject.activeElement; if (state === 'loading') return; if (state === 'ready') { openWidget(query, Boolean(submit && query)); @@ -237,6 +239,54 @@ ensureScript(serial, query, Boolean(submit && query), retrying); } + function cancelConsent() { + pending = null; + renderState('idle', ''); + if (consent && consent.open) consent.close(); + restoreFocus(); + } + + function activate(query, submit, trigger) { + if (state === 'loading' || state === 'consent') return; + lastTrigger = trigger || documentObject.activeElement; + if (consented) { + load(query, submit); + return; + } + // Fail closed if the local consent panel is unavailable. + if (!consent || typeof consent.showModal !== 'function') { + renderState('error', config.labels.error); + return; + } + pending = { query: trimmedQuery(query), submit: submit }; + renderState('consent', ''); + consent.showModal(); + } + + if (consent) { + // Keep the underlying search palette from consuming modal keyboard events. + consent.addEventListener('keydown', function (event) { + event.stopPropagation(); + if (event.key === 'Escape') { + event.preventDefault(); + cancelConsent(); + } + }); + consent.querySelector('[data-hg-ai-continue]').addEventListener('click', function () { + if (!pending) return; + var request = pending; + pending = null; + consented = true; + consent.close(); + load(request.query, request.submit); + }); + consent.querySelector('[data-hg-ai-cancel]').addEventListener('click', cancelConsent); + consent.addEventListener('cancel', function (event) { + event.preventDefault(); + cancelConsent(); + }); + } + function restoreFocus() { if (lastTrigger && typeof lastTrigger.focus === 'function') { lastTrigger.focus(); @@ -274,6 +324,21 @@ } documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(bind); + if (!input || !list) return controller; + + // Keep the OINK palette untouched: only intercept Enter when local search + // is empty and the site-owned Ask AI tail is the available follow-up. + input.addEventListener('keydown', function (event) { + if (event.isComposing || event.keyCode === 229 || event.key !== 'Enter') return; + var empty = list.querySelector('.td-shell-search__empty'); + var localRow = list.querySelector('.td-shell-search__item:not(.hg-ai-search-tail__button)'); + var tailButton = list.querySelector('[data-hg-ai-search-tail] [data-hg-ask-ai]'); + if (!empty || localRow || !tailButton) return; + event.preventDefault(); + event.stopImmediatePropagation(); + controller.activate(input.value, true, tailButton); + }, true); + function syncTail() { syncing = false; if (!root || !input || !list || root.hidden) return; @@ -305,6 +370,8 @@ if (old) old.remove(); return; } + var empty = list.querySelector('.td-shell-search__empty'); + if (empty && config.labels.noResults) empty.textContent = config.labels.noResults; var oldButton = old && old.querySelector('[data-hg-ask-ai]'); if (oldButton && oldButton.dataset.hgAiQuery === query) return; if (old) old.remove(); @@ -333,9 +400,7 @@ 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 + '.' : ''); + detail.textContent = config.historical ? config.labels.latest + '.' : ''; meta.appendChild(title); meta.appendChild(detail); row.appendChild(icon); diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index 14bfd7e77c..9ebca8f7c0 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -352,6 +352,26 @@ } } } + + // A hover preview is a continuation of the global header, rather than a + // second offset card. Align its panel to the same top and left edges so the + // brand row and navbar read as one surface while the normal sidebar keeps + // its authored overlay geometry. + [data-td-shell-sidebar='collapsed'] + .td-shell-sidebar--overlay + .td-shell-sidebar__panel { + inset-block-start: 0; + transform: translateX(0); + border-block-start: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; + } + + [dir='rtl'][data-td-shell-sidebar='collapsed'] + .td-shell-sidebar--overlay + .td-shell-sidebar__panel { + transform: translateX(0); + } } @media (forced-colors: active) and (min-width: 768px) { @@ -491,28 +511,37 @@ display: none; } -@media (min-width: 768px) { - [data-td-shell-sidebar='collapsed'] { - #td-shell-sidebar { - visibility: hidden; - pointer-events: none; - } +.hg-sidebar-edge { + position: fixed; + inset-inline-start: 0; + top: 64px; + bottom: 0; + width: 16px; + z-index: 121; + border-inline-start: 3px solid var(--bs-border-color); - // 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; - } + &:hover { border-inline-start-color: var(--bs-primary); } +} +@media (min-width: 768px) { + [data-td-shell-sidebar='collapsed'] { .td-nav-util.hg-sidebar-restore:not([hidden]) { display: inline-flex; } } } +.hg-version-overflow { + color: var(--bs-body-color); + summary { + cursor: pointer; + padding: .5rem .75rem; + list-style: none; + &::-webkit-details-marker { display: none; } + &:focus-visible { outline: 2px solid var(--bs-primary); } + } +} + .hg-search-retry { display: flex; align-items: center; @@ -528,6 +557,13 @@ } .hg-ai-search-tail { + position: sticky; + bottom: 0; + z-index: 1; + margin-inline: -6px; + padding-inline: 6px; + background: var(--td-shell-popover); + box-shadow: 0 -8px 16px color-mix(in srgb, var(--td-shell-popover) 88%, transparent); border-block-start: 1px solid var(--bs-border-color); &__button { @@ -545,11 +581,25 @@ } } +/* Keep the optional Ask AI tail discoverable without changing the OINK theme. */ +.td-shell-search__panel { + max-height: unquote('min(84vh, 720px)'); +} + +@media (max-width: 767.98px) { + .td-shell-search__panel { + max-height: unquote('min(86vh, 680px)'); + } +} + .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))'); + // Keep the launcher clear of the footer while preserving the safe-area + // inset on mobile. The offset is intentionally site-owned so OINK upgrades + // do not require changing the widget or theme source. + inset-block-end: unquote('calc(max(1rem, env(safe-area-inset-bottom)) + 4rem)'); display: inline-flex; align-items: center; gap: 0.45rem; @@ -583,7 +633,7 @@ 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)' + 'calc(max(1rem, env(safe-area-inset-bottom)) + 6.8rem)' ); width: unquote('min(18rem, calc(100vw - 2rem))'); padding: 0.55rem 0.7rem; @@ -645,7 +695,8 @@ gap: 0.35rem; > a, - > button { + > button, + > .hg-version-overflow > a { display: inline-flex; align-items: center; gap: 0.4rem; @@ -660,12 +711,18 @@ } > a[aria-current='page'], + > .hg-version-overflow > a[aria-current='page'], > button[aria-pressed='true'] { background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis); } } + .hg-shell-mobile-utils__links > .hg-version-overflow > a { + margin-inline-end: 0.35rem; + margin-block-start: 0.35rem; + } + .hg-shell-mobile-utils__github { display: inline-flex; align-items: center; @@ -834,3 +891,56 @@ } } } + +// ASF download tables (layouts/_partials/asf-downloads.html): keep the cells +// on one line so the wide table scrolls inside td-asset-list__table-wrap +// instead of squeezing every column, and lay the release meta out inline. +.hg-asf-release { + // The focusable wrapper owns horizontal scrolling, not td-content's table. + .td-asset-list__table-wrap { + overflow-x: auto !important; + overflow-y: hidden; + } + + .td-asset-list__table { + display: table !important; + width: max-content; + min-width: 100%; + overflow: visible !important; + } + + .td-asset-list__table th, + .td-asset-list__table td { + white-space: nowrap; + } +} + +.hg-asf-release__meta { + list-style: none; + padding-left: 0; + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.5rem; +} + + +// Local disclosure renders before any third-party AI resource is requested. +.hg-ai-consent { + width: calc(100% - 2rem); + max-width: 28rem; + padding: 1.5rem; + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + background: var(--bs-body-bg); + color: var(--bs-body-color); + box-shadow: 0 .75rem 3rem rgba(0, 0, 0, .2); + &::backdrop { background: rgba(0, 0, 0, .4); } + h2 { font-size: 1.25rem; } + .hg-ai-consent-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: .75rem; + margin-top: 1.25rem; + } +} diff --git a/assets/scss/community-members.scss b/assets/scss/community-members.scss new file mode 100644 index 0000000000..0ed28310f4 --- /dev/null +++ b/assets/scss/community-members.scss @@ -0,0 +1,99 @@ +.hg-community-members { + --hg-community-columns: 4; + padding-block: 3rem; + &__role { + padding-block: 1.5rem; + } + &__role + &__role { margin-top: 1rem; } + .td-landing-section__header h2 { + margin-bottom: .65rem; + font-size: clamp(2rem, 3vw, 2.75rem); + letter-spacing: -.035em; + } + &__grid { + --td-columns: var(--hg-community-columns); + display: grid; + grid-template-columns: repeat(var(--hg-community-columns), minmax(0, 1fr)); + gap: 1rem; + padding: 0; + margin: 1rem 0 0; + list-style: none; + } +} +.td-default main .hg-community-members { padding: 3rem 0; } +.td-default main .hg-community-members__role { padding: 1.5rem 0; } +.hg-community-member { + display: block; + min-width: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + &__surface { + display: flex; + width: 100%; + min-height: 5.25rem; + align-items: center; + gap: .9rem; + padding: .85rem 1rem; + color: inherit; + text-decoration: none; + border: 1px solid var(--td-line, var(--bs-border-color)); + border-radius: 1rem; + background: var(--td-card-bg, var(--bs-body-bg)); + box-shadow: 0 .35rem 1rem rgba(35, 45, 75, .07); + } + &__link { + transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; + &:hover, &:focus-visible { + color: var(--td-link-color, var(--bs-link-color)); + border-color: currentColor; + box-shadow: 0 .55rem 1.3rem rgba(35, 45, 75, .13); + transform: translateY(-1px); + } + } + &__avatar { + position: relative; + display: grid; + flex: 0 0 3.35rem; + width: 3.35rem; + height: 3.35rem; + overflow: hidden; + place-items: center; + border-radius: 50%; + background: var(--td-secondary-bg, var(--bs-secondary-bg)); + img, .hg-community-member__initials { position: absolute; inset: 0; width: 100%; height: 100%; } + img { object-fit: cover; } + } + &__initials { + display: grid; + place-items: center; + font-size: 1rem; + font-weight: 700; + color: var(--td-body-color, var(--bs-body-color)); + } + &__identity { min-width: 0; max-width: 100%; overflow-wrap: anywhere; font-size: .96rem; font-weight: 700; } +} +@media (max-width: 1199.98px) { + .hg-community-members { --hg-community-columns: 3; } +} +@media (max-width: 767.98px) { + .hg-community-members { + --hg-community-columns: 2; + padding: 2rem 0; + } + .td-default main .hg-community-members { padding: 2rem 0; } + .td-default main .hg-community-members__role { padding: 1rem 0; } + .hg-community-members__grid { gap: .75rem; } + .hg-community-member__surface { gap: .65rem; padding: .7rem; } + .hg-community-member__avatar { + flex-basis: 2.8rem; + width: 2.8rem; + height: 2.8rem; + } + .hg-community-member__identity { + font-size: .9rem; + overflow-wrap: break-word; + } +} diff --git a/content/cn/docs/_nav/operate.md b/content/cn/docs/_nav/operate.md index 5516b6ab7b..99b13577ac 100644 --- a/content/cn/docs/_nav/operate.md +++ b/content/cn/docs/_nav/operate.md @@ -1,6 +1,6 @@ --- -title: "运维" -linkTitle: "运维" +title: "配置" +linkTitle: "配置" description: "从配置开始,再查看安全、备份和性能调优指南。" manual_link: /cn/docs/config/ search_exclude: true diff --git a/content/cn/docs/clients/_index.md b/content/cn/docs/clients/_index.md index b31bade126..5fd06188dc 100644 --- a/content/cn/docs/clients/_index.md +++ b/content/cn/docs/clients/_index.md @@ -1,7 +1,10 @@ --- title: "客户端与 API" +description: "通过 Java 客户端、REST API、Gremlin Console 和其他客户端库连接 HugeGraph。" linkTitle: "客户端与 API" weight: 5 +search_keywords: [HugeGraph 客户端, Java 客户端, 客户端库] +search_boost: 1.5 --- 本节包含 REST API、Gremlin Console 和客户端说明。当前 Server REST API 使用图空间和图名称组成资源路径;具体路径以各 API 页面和 Server 的 OpenAPI 页面为准。 diff --git a/content/cn/docs/clients/restful-api/_index.md b/content/cn/docs/clients/restful-api/_index.md index 6094321b29..ab79ed10b8 100644 --- a/content/cn/docs/clients/restful-api/_index.md +++ b/content/cn/docs/clients/restful-api/_index.md @@ -2,6 +2,8 @@ title: "HugeGraph RESTful API" linkTitle: "RESTful API" weight: 1 +search_keywords: [HugeGraph REST API, RESTful API, OpenAPI] +search_boost: 1.7 --- > ⚠️ **版本兼容性说明** diff --git a/content/cn/docs/clients/restful-api/vertex.md b/content/cn/docs/clients/restful-api/vertex.md index 1318da72f2..3e9c5d69f1 100644 --- a/content/cn/docs/clients/restful-api/vertex.md +++ b/content/cn/docs/clients/restful-api/vertex.md @@ -5,7 +5,7 @@ weight: 7 description: "Vertex(顶点)REST 接口:创建、查询、更新和删除图中的顶点数据,支持批量操作和条件过滤。" --- -### 2.1 Vertex +## 2.1 Vertex {#vertex-api} 顶点类型中的 `Id` 策略决定了顶点的 `Id` 类型,其对应的 `id` 类型如下: @@ -16,6 +16,7 @@ description: "Vertex(顶点)REST 接口:创建、查询、更新和删除图 | CUSTOMIZE_STRING | string | | CUSTOMIZE_NUMBER | number | | CUSTOMIZE_UUID | uuid | +{#vertex-id-strategy .full-width caption="顶点 ID 策略"} 顶点的 `GET/PUT/DELETE` API 中 url 的 id 部分应该传入带有类型信息的 id 值,这个类型信息通过 json 串是否带引号来表示,也就是说: @@ -41,24 +42,24 @@ schema.vertexLabel("software").properties("name", "lang", "price").primaryKeys(" schema.indexLabel("personByAge").onV("person").by("age").range().ifNotExist().create(); ``` -#### 2.1.1 创建一个顶点 +### 2.1.1 创建一个顶点 {#create-vertex} -##### Params +#### Params **路径参数说明:** - graphspace: 图空间名称 - graph: 图名称 -##### Method & Url +#### Method & Url ``` POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ``` -##### Request Body +#### Request Body -```json +```json {filename="request.json" wrap=true} { "label": "person", "properties": { @@ -68,15 +69,15 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices } ``` -##### Response Status +#### Response Status ```json 201 ``` -##### Response Body +#### Response Body -```json +```json {filename="response.json" wrap=true} { "id": "1:marko", "label": "person", @@ -88,22 +89,22 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices } ``` -#### 2.1.2 创建多个顶点 +### 2.1.2 创建多个顶点 -##### Params +#### Params **路径参数说明:** - graphspace: 图空间名称 - graph: 图名称 -##### Method & Url +#### Method & Url ``` POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Request Body +#### Request Body ```json [ @@ -125,13 +126,13 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/b ] ``` -##### Response Status +#### Response Status ```json 201 ``` -##### Response Body +#### Response Body ```json [ @@ -140,9 +141,9 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/b ] ``` -#### 2.1.3 更新顶点属性 +### 2.1.3 更新顶点属性 -##### Params +#### Params **路径参数说明:** @@ -150,13 +151,13 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/b - graph: 图名称 - id: 顶点 id,需要包含引号,例如"1:marko" -##### Method & Url +#### Method & Url ``` PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko"?action=append ``` -##### Request Body +#### Request Body ```json { @@ -170,13 +171,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 > 注意:属性的取值有三种类别,分别为 single、set 和 list。single 表示增加或更新属性值,set 或 list 表示追加属性值。 -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -191,9 +192,9 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 } ``` -#### 2.1.4 批量更新顶点属性 +### 2.1.4 批量更新顶点属性 -##### 功能说明 +#### 功能说明 批量更新顶点的属性时,可以选择多种更新策略,如下: @@ -246,20 +247,20 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 curl -H "Content-Type: application/json" -d '[{"label":"person","properties":{"name":"josh","age":32,"city":"Beijing","weight":0.1,"hobby":["reading","football"]}},{"label":"software","properties":{"name":"lop","lang":"java","price":328}}]' http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Params +#### Params **路径参数说明:** - graphspace: 图空间名称 - graph: 图名称 -##### Method & Url +#### Method & Url ``` PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Request Body +#### Request Body ```json { @@ -297,13 +298,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/ba } ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -349,9 +350,9 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/ba 其他更新策略的使用方式与此类似,此处不再详述。 -#### 2.1.5 删除顶点属性 +### 2.1.5 删除顶点属性 -##### Params +#### Params **路径参数说明:** @@ -359,13 +360,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/ba - graph: 图名称 - id: 顶点 id,需要包含引号,例如"1:marko" -##### Method & Url +#### Method & Url ``` PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko"?action=eliminate ``` -##### Request Body +#### Request Body ```json { @@ -378,13 +379,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 > 注意:这里会直接删除属性(删除 key 和所有 value),无论其属性的取值是 single、set 或 list。 -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -398,9 +399,9 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 } ``` -#### 2.1.6 获取符合条件的顶点 +### 2.1.6 获取符合条件的顶点 -##### Params +#### Params **路径参数说明:** @@ -435,19 +436,19 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 **查询所有 age 为 29 且 label 为 person 的顶点** -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?label=person&properties={"age":29}&limit=1 ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -473,19 +474,19 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?la curl -H "Content-Type: application/json" -d '[{"label":"person","properties":{"name":"peter","age":29,"city":"Shanghai"}},{"label":"person","properties":{"name":"vadas","age":27,"city":"Hongkong"}}]' http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?page&limit=3 ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -534,19 +535,19 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?pa **分页查询所有顶点,获取下一页(page 带上上一页返回的 page 值),限定 3 条** -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?page=CIYxOnBldGVyAAAAAAAAAAM=&limit=3 ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -588,9 +589,9 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?pa 当`"page": null`时,表示已经没有下一页了(注:如果后端使用的是 Cassandra,为了提高性能,当返回的页数刚好是最后一页时,返回的 `page` 值可能不为空,但是如果用这个 `page` 值再请求下一页数据时,就会返回 `空数据` 和 `page = null`,其他情况也类似) -#### 2.1.7 根据 Id 获取顶点 +### 2.1.7 根据 Id 获取顶点 -##### Params +#### Params **路径参数说明:** @@ -598,19 +599,19 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?pa - graph: 图名称 - id: 顶点 id,需要包含引号,例如"1:marko" -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko" ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -624,9 +625,9 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 } ``` -#### 2.1.8 根据 Id 删除顶点 +### 2.1.8 根据 Id 删除顶点 -##### Params +#### Params **路径参数说明:** @@ -640,13 +641,13 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 **仅根据 Id 删除顶点** -##### Method & Url +#### Method & Url ``` DELETE http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko" ``` -##### Response Status +#### Response Status ```json 204 @@ -656,13 +657,13 @@ DELETE http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices 通过指定 Label 参数和 Id 来删除顶点时,一般来说其性能比仅根据 Id 删除会更好。 -##### Method & Url +#### Method & Url ``` DELETE http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko"?label=person ``` -##### Response Status +#### Response Status ```json 204 diff --git a/content/cn/docs/config/config-authentication.md b/content/cn/docs/config/config-authentication.md index a6b67d1b5c..481edd5038 100644 --- a/content/cn/docs/config/config-authentication.md +++ b/content/cn/docs/config/config-authentication.md @@ -2,6 +2,8 @@ title: "HugeGraph 内置用户权限与扩展权限配置及使用" linkTitle: "权限配置" weight: 3 +search_keywords: [HugeGraph 认证, 权限配置, StandardAuthenticator] +search_boost: 1.7 --- ### 概述 diff --git a/content/cn/docs/config/config-guide.md b/content/cn/docs/config/config-guide.md index c8d8d3c942..7d76a80f6f 100644 --- a/content/cn/docs/config/config-guide.md +++ b/content/cn/docs/config/config-guide.md @@ -2,6 +2,8 @@ title: "Server 启动指南" linkTitle: "Server 启动指南" weight: 1 +search_keywords: [HugeGraph 配置, Server 配置, 配置指南] +search_boost: 1.6 --- ### 1 概述 @@ -21,7 +23,7 @@ HugeGraphServer 内部集成了 GremlinServer 和 RestServer,而 gremlin-serve `gremlin-server.yaml` 的主要结构如下。示例省略了部分导入项;完整内容以发布包中的文件为准。 -```yaml +```yaml {filename="conf/gremlin-server.yaml" wrap=true collapse=18} # host and port of gremlin server, need to be consistent with host and port in rest-server.properties #host: 127.0.0.1 #port: 8182 diff --git a/content/cn/docs/download/download.md b/content/cn/docs/download/download.md index 831c22faaa..c9c67ef5a5 100644 --- a/content/cn/docs/download/download.md +++ b/content/cn/docs/download/download.md @@ -2,6 +2,8 @@ title: "下载 Apache HugeGraph" linkTitle: "Download" weight: 2 +search_keywords: [HugeGraph 下载, 发布包, SHA512] +search_boost: 3 --- > 指南: @@ -11,25 +13,11 @@ weight: 2 > - 检查哈希 (SHA512)、签名的说明在 [版本验证](/docs/contribution-guidelines/validate-release/) 页面, 也可参考 [ASF 验证说明](https://www.apache.org/dyn/closer.cgi#verify) > - 注: HugeGraph 所有组件版本号已保持一致, `client/loader/hubble/common` 等 maven 仓库版本号同理, 依赖引用可参考 [maven 示例](https://github.com/apache/hugegraph-toolchain#maven-dependencies) > - 兼容说明: HugeGraph 于 2026 年 1 月毕业后,下载路径已从 `/incubator/hugegraph` 迁移到 `/hugegraph`。历史版本的发布文件名可能仍包含 `-incubating-`。 +> - 从源码构建请参考 [编译构建说明](/cn/docs/quickstart/hugegraph/hugegraph-server/) -### 最新版本 1.7.0 +### 最新版本 -- Release Date: 2025-11-28 -- [Release Notes](/docs/changelog/hugegraph-1.7.0-release-notes/) - -#### 二进制包 - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz.sha512)] | - -#### 源码包 - -Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/). - -| Server | Toolchain | AI | Computer | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-ai-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-ai-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-ai-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-computer-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-computer-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-computer-incubating-1.7.0-src.tar.gz.sha512)] | +{{< asf-downloads latest >}} --- @@ -41,75 +29,4 @@ Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/ > 2. `1.3.0` 是最后一个兼容 Java8 的主版本, 请尽早使用/迁移运行时为 Java11 (低版本 Java 有潜在更多的 SEC 风险和性能影响) > 3. 从版本 `1.5.0` 开始,需要 Java11 运行时环境 -#### 1.5.0 - -- Release Date: 2024-12-10 -- [Release Notes](/docs/changelog/hugegraph-1.5.0-release-notes/) - -##### 二进制包 - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz.sha512)] | - -##### 源码包 - -Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/). - -| Server | Toolchain | AI | Computer | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-ai-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-ai-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-ai-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-computer-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-computer-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-computer-incubating-1.5.0-src.tar.gz.sha512)] | - -#### 1.3.0 - -- Release Date: 2024-04-01 -- [Release Notes](/docs/changelog/hugegraph-1.3.0-release-notes/) - -##### 二进制包 - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz.sha512)] | - -##### 源码包 - -Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server/). - -| Server | Toolchain | AI | Common | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-ai-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-ai-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-ai-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-commons-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-commons-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-commons-incubating-1.3.0-src.tar.gz.sha512)] | - - -#### 1.2.0 - -- Release Date: 2023-12-28 -- [Release Notes](/docs/changelog/hugegraph-1.2.0-release-notes/) - -##### 二进制包 - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz.sha512)] | - -##### 源码包 - -| Server | Toolchain | Computer | Common | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-computer-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-computer-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-computer-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-commons-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-commons-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-commons-incubating-1.2.0-src.tar.gz.sha512)] | - -#### 1.0.0 - -- Release Date: 2023-02-22 -- [Release Notes](/docs/changelog/hugegraph-1.0.0-release-notes/) - -##### 二进制包 - -| Server | Toolchain | Computer | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0.tar.gz.sha512)] | - -##### 源码包 - -| Server | Toolchain | Computer | Common | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-commons-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-commons-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-commons-incubating-1.0.0-src.tar.gz.sha512)] | +{{< asf-downloads archived >}} diff --git a/content/cn/docs/introduction/_index.md b/content/cn/docs/introduction/_index.md index d57559c4dc..26a64a2015 100644 --- a/content/cn/docs/introduction/_index.md +++ b/content/cn/docs/introduction/_index.md @@ -2,6 +2,8 @@ title: "Apache HugeGraph 介绍" linkTitle: "系统介绍" weight: 1 +search_keywords: [HugeGraph 介绍, 图数据库简介, 系统架构] +search_boost: 3 aliases: # Hugo 0.165 prefixes aliases with the current language path. - /docs/introduction/readme/ @@ -111,7 +113,7 @@ HugeGraph-AI 连接图技术与大语言模型、图机器学习框架。仓库 | 运行图算法 | [Vermeer 与 Computer](/cn/docs/quickstart/computing/) | | 构建 GraphRAG 或图机器学习应用 | [HugeGraph-AI](/cn/docs/quickstart/hugegraph-ai/) | -## 社区 +## 社区 {#community} - [GitHub Issues](https://github.com/apache/hugegraph/issues) - 开发者邮件列表:[dev@hugegraph.apache.org](mailto:dev@hugegraph.apache.org) diff --git a/content/cn/docs/quickstart/computing/hugegraph-computer.md b/content/cn/docs/quickstart/computing/hugegraph-computer.md index 297d0f7fc4..134e9f3b1a 100644 --- a/content/cn/docs/quickstart/computing/hugegraph-computer.md +++ b/content/cn/docs/quickstart/computing/hugegraph-computer.md @@ -2,6 +2,8 @@ title: "HugeGraph-Computer Quick Start" linkTitle: "使用 Computer 进行 OLAP 分析" weight: 2 +search_keywords: [HugeGraph Computer, 图计算, OLAP] +search_boost: 1.6 --- ## 1 HugeGraph-Computer 概述 diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md b/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md index 34d5db1f01..f41c3d7e62 100644 --- a/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md +++ b/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md @@ -3,10 +3,12 @@ title: "HugeGraph-Store Quick Start" linkTitle: "安装/构建 HugeGraph-Store" weight: 3 search_keywords: - - server.port + - HugeGraph HStore + - 分布式存储 - REST 端口 - Store REST 端口 -search_boost: 1.5 + - server.port +search_boost: 1.6 --- ### 1 HugeGraph-Store 概述 diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md b/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md index 532d11f893..403283cb3c 100644 --- a/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md +++ b/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md @@ -2,6 +2,8 @@ title: "HugeGraph-PD Quick Start" linkTitle: "安装/构建 HugeGraph-PD" weight: 2 +search_keywords: [HugeGraph PD, 元数据管理, 集群调度] +search_boost: 1.6 --- ### 1 HugeGraph-PD 概述 diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-server.md b/content/cn/docs/quickstart/hugegraph/hugegraph-server.md index 1e08171ff7..bddbb9020e 100644 --- a/content/cn/docs/quickstart/hugegraph/hugegraph-server.md +++ b/content/cn/docs/quickstart/hugegraph/hugegraph-server.md @@ -2,6 +2,8 @@ title: "HugeGraph Server 快速开始" linkTitle: "安装/构建 HugeGraph Server" weight: 1 +search_keywords: [HugeGraph Server, Server 快速开始, 图数据库服务] +search_boost: 1.7 aliases: - /docs/quickstart/hugegraph-server/ --- @@ -34,10 +36,11 @@ HugeGraph 1.7.0 中的 `hugegraph-server` 模块使用 Java 11 编译,运行 有四种方式可以部署 Server 服务: -- 方式 1:使用 Docker 容器 (便于**测试**) -- 方式 2:下载 tar 包 -- 方式 3:源码编译 -- 方式 4:使用 tools 工具部署 (Outdated) +1. 使用 Docker 容器进行测试或开发。 +1. 下载二进制 tar 包。 +1. 从源码编译。 +1. 使用已过时的一键部署工具。 +{.steps} > 不要把 Gremlin、Cypher 等查询接口直接暴露到公网。生产环境应启用[认证与授权](/cn/docs/config/config-authentication/),限制网络访问并保留审计日志;部署建议见[安全指南](/cn/docs/guides/security/)。 @@ -72,7 +75,7 @@ HugeGraph 1.7.0 中的 `hugegraph-server` 模块使用 Java 11 编译,运行 | HA 参考 | `docker-compose-3pd-3store-3server.yml` | 3 PD + 3 Store + 3 Server + 1 Hubble | | 最小 HStore 拓扑的源码构建覆盖文件 | `docker-compose.dev.yml` | (需与 `docker-compose-hstore.yml` 一起使用) | -```bash +```bash {wrap=true} cd hugegraph/docker # 注意版本号请随时保持更新 → 1.x.0 HUGEGRAPH_VERSION=1.7.0 docker compose -f docker-compose.yml up -d --wait @@ -92,7 +95,7 @@ compose 文件从 `HUGEGRAPH_ADMIN_PASSWORD` 读取管理员密码,从 `HUGEGR ### 3.2 下载 tar 包 -```bash +```bash {filename="download-release.sh" wrap=true collapse=2} # 1.7.0 是项目孵化期发布的历史版本,因此文件名仍带 incubating wget https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz tar zxf apache-hugegraph-incubating-1.7.0.tar.gz @@ -104,7 +107,7 @@ tar zxf apache-hugegraph-incubating-1.7.0.tar.gz 下载 HugeGraph 源代码 -```bash +```bash {filename="build-from-source.sh" wrap=true collapse=2} git clone https://github.com/apache/hugegraph.git ``` diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md index 92f2ac76e8..313e5173cd 100644 --- a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md +++ b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md @@ -1,7 +1,10 @@ --- title: "HugeGraph-Hubble Quick Start" +description: "部署 HugeGraph-Hubble,进行图可视化、元数据管理、数据导入,以及 Gremlin 或 Cypher 查询。" linkTitle: "使用 Hubble 实现图可视化" weight: 1 +search_keywords: [HugeGraph Hubble, 图可视化, Web 管理界面] +search_boost: 1.6 --- ### 1 HugeGraph-Hubble 概述 diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-loader.md b/content/cn/docs/quickstart/toolchain/hugegraph-loader.md index d58ff7785c..5a9916bdc1 100644 --- a/content/cn/docs/quickstart/toolchain/hugegraph-loader.md +++ b/content/cn/docs/quickstart/toolchain/hugegraph-loader.md @@ -2,6 +2,8 @@ title: "HugeGraph-Loader Quick Start" linkTitle: "使用 Loader 实时/流式导入数据" weight: 2 +search_keywords: [HugeGraph Loader, 批量导入, 数据导入] +search_boost: 1.6 --- ### 1 HugeGraph-Loader 概述 diff --git a/content/en/docs/clients/_index.md b/content/en/docs/clients/_index.md index 48a709f4f2..3ac8c6fc20 100644 --- a/content/en/docs/clients/_index.md +++ b/content/en/docs/clients/_index.md @@ -1,7 +1,10 @@ --- title: "Clients and APIs" +description: "Connect to HugeGraph with the Java client, REST API, Gremlin Console, and other client libraries." linkTitle: "Clients and APIs" weight: 5 +search_keywords: [HugeGraph clients, Java client, client libraries] +search_boost: 1.5 --- This section covers the REST API, Gremlin Console, and client libraries. The current Server REST API identifies graph resources with both a graph space and a graph name. Refer to each API page and the Server OpenAPI page for the exact paths. diff --git a/content/en/docs/clients/restful-api/_index.md b/content/en/docs/clients/restful-api/_index.md index 7c35ce9e30..764a37fb28 100644 --- a/content/en/docs/clients/restful-api/_index.md +++ b/content/en/docs/clients/restful-api/_index.md @@ -2,6 +2,9 @@ title: "HugeGraph RESTful API" linkTitle: "RESTful API" weight: 1 +search_keywords: [HugeGraph REST API, RESTful API, OpenAPI] +search_boost: 1.7 +description: "HugeGraph RESTful API reference for graph, schema, vertex, and edge operations." --- > ⚠️ **Version compatibility notes** diff --git a/content/en/docs/clients/restful-api/vertex.md b/content/en/docs/clients/restful-api/vertex.md index 52d604c4ee..2792a2d413 100644 --- a/content/en/docs/clients/restful-api/vertex.md +++ b/content/en/docs/clients/restful-api/vertex.md @@ -5,7 +5,7 @@ weight: 7 description: "Vertex REST API: Create, query, update, and delete vertex data in the graph with support for batch operations and conditional filtering." --- -### 2.1 Vertex +## 2.1 Vertex {#vertex-api} In vertex types, the `Id` strategy determines the type of the vertex `Id`, with the corresponding relationships as follows: @@ -16,6 +16,7 @@ In vertex types, the `Id` strategy determines the type of the vertex `Id`, with | CUSTOMIZE_STRING | string | | CUSTOMIZE_NUMBER | number | | CUSTOMIZE_UUID | uuid | +{#vertex-id-strategy .full-width caption="Vertex ID strategies"} For the `GET/PUT/DELETE` API of a vertex, the id part in the URL should be passed as the id value with type information. This type information is indicated by whether the JSON string is enclosed in quotes, meaning: @@ -41,17 +42,17 @@ schema.vertexLabel("software").properties("name", "lang", "price").primaryKeys(" schema.indexLabel("personByAge").onV("person").by("age").range().ifNotExist().create(); ``` -#### 2.1.1 Create a vertex +### 2.1.1 Create a vertex {#create-vertex} -##### Method & Url +#### Method & Url ``` POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ``` -##### Request Body +#### Request Body -```json +```json {filename="request.json" wrap=true} { "label": "person", "properties": { @@ -61,15 +62,15 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices } ``` -##### Response Status +#### Response Status ```json 201 ``` -##### Response Body +#### Response Body -```json +```json {filename="response.json" wrap=true} { "id": "1:marko", "label": "person", @@ -81,15 +82,15 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices } ``` -#### 2.1.2 Create multiple vertices +### 2.1.2 Create multiple vertices -##### Method & Url +#### Method & Url ``` POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Request Body +#### Request Body ```json [ @@ -111,13 +112,13 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/b ] ``` -##### Response Status +#### Response Status ```json 201 ``` -##### Response Body +#### Response Body ```json [ @@ -126,15 +127,15 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/b ] ``` -#### 2.1.3 Update vertex properties +### 2.1.3 Update vertex properties -##### Method & Url +#### Method & Url ``` PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko"?action=append ``` -##### Request Body +#### Request Body ```json { @@ -148,13 +149,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 > Note: There are three categories for property values: single, set, and list. If it is single, it means adding or updating the property value. If it is set or list, it means appending the property value. -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -169,9 +170,9 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 } ``` -#### 2.1.4 Batch Update Vertex Properties +### 2.1.4 Batch Update Vertex Properties -##### Function Description +#### Function Description Batch update properties of vertices and support various update strategies, including: @@ -224,13 +225,13 @@ Add vertices with the following command: curl -H "Content-Type: application/json" -d '[{"label":"person","properties":{"name":"josh","age":32,"city":"Beijing","weight":0.1,"hobby":["reading","football"]}},{"label":"software","properties":{"name":"lop","lang":"java","price":328}}]' http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Method & Url +#### Method & Url ``` PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Request Body +#### Request Body ```json { @@ -268,13 +269,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/ba } ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -320,15 +321,15 @@ Result Analysis: The usage of other update strategies can be inferred in a similar manner and will not be further elaborated. -#### 2.1.5 Delete Vertex Properties +### 2.1.5 Delete Vertex Properties -##### Method & Url +#### Method & Url ``` PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko"?action=eliminate ``` -##### Request Body +#### Request Body ```json { @@ -341,13 +342,13 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 > Note: Here, the properties (keys and all values) will be directly deleted, regardless of whether the property values are single, set, or list. -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -361,9 +362,9 @@ PUT http://127.0.0.1:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 } ``` -#### 2.1.6 Get Vertices that Meet the Criteria +### 2.1.6 Get Vertices that Meet the Criteria -##### Params +#### Params - label: Vertex type - properties: Property key-value pairs (precondition: indexes are created for property queries) @@ -391,19 +392,19 @@ Property key-value pairs consist of the property name and value in JSON format. **Query all vertices with age 29 and label person** -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?label=person&properties={"age":29}&limit=1 ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -429,19 +430,19 @@ Add vertices with the following command: curl -H "Content-Type: application/json" -d '[{"label":"person","properties":{"name":"peter","age":29,"city":"Shanghai"}},{"label":"person","properties":{"name":"vadas","age":27,"city":"Hongkong"}}]' http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/batch ``` -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?page&limit=3 ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -490,19 +491,19 @@ The returned `body` contains information about the page number of the next `page **Paginate and retrieve all vertices, including the next page (passing the `page` value returned from the previous page), limited to 3 items.** -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?page=CIYxOnBldGVyAAAAAAAAAAM=&limit=3 ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -544,21 +545,21 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices?pa At this point, `"page": null` indicates that there are no more pages available. (Note: When using Cassandra as the backend for performance reasons, if the returned page happens to be the last page, the `page` value may not be empty. When requesting the next page using that `page` value, it will return `empty data` and `page = null`. The same applies to other similar situations.) -#### 2.1.7 Retrieve Vertex by ID +### 2.1.7 Retrieve Vertex by ID -##### Method & Url +#### Method & Url ``` GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko" ``` -##### Response Status +#### Response Status ```json 200 ``` -##### Response Body +#### Response Body ```json { @@ -572,21 +573,21 @@ GET http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1 } ``` -#### 2.1.8 Delete Vertex by ID +### 2.1.8 Delete Vertex by ID -##### Params +#### Params - label: Vertex type, optional parameter **Delete the vertex based on ID only.** -##### Method & Url +#### Method & Url ``` DELETE http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko" ``` -##### Response Status +#### Response Status ```json 204 @@ -596,13 +597,13 @@ DELETE http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices When deleting a vertex by specifying both the Label parameter and the ID, it generally offers better performance compared to deleting by ID alone. -##### Method & Url +#### Method & Url ``` DELETE http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"1:marko"?label=person ``` -##### Response Status +#### Response Status ```json 204 diff --git a/content/en/docs/config/config-authentication.md b/content/en/docs/config/config-authentication.md index f5de712c55..2461c43ea5 100644 --- a/content/en/docs/config/config-authentication.md +++ b/content/en/docs/config/config-authentication.md @@ -2,6 +2,8 @@ title: "Built-in User Authentication and Authorization Configuration and Usage in HugeGraph" linkTitle: "Config Authentication" weight: 3 +search_keywords: [HugeGraph authentication, authorization, StandardAuthenticator] +search_boost: 1.7 --- ### Overview diff --git a/content/en/docs/config/config-guide.md b/content/en/docs/config/config-guide.md index 3e24f49ed9..d8a583f422 100644 --- a/content/en/docs/config/config-guide.md +++ b/content/en/docs/config/config-guide.md @@ -2,6 +2,8 @@ title: "Server Startup Guide" linkTitle: "Server Startup Guide" weight: 1 +search_keywords: [HugeGraph configuration, server config, configuration guide] +search_boost: 1.6 --- ### 1 Overview @@ -21,7 +23,7 @@ Now let's introduce these three configuration files one by one. The main structure of `gremlin-server.yaml` is shown below. Some imports are omitted from this example; refer to the file included in the release package for the complete content. -```yaml +```yaml {filename="conf/gremlin-server.yaml" wrap=true collapse=18} # host and port of gremlin server, need to be consistent with host and port in rest-server.properties #host: 127.0.0.1 #port: 8182 diff --git a/content/en/docs/download/download.md b/content/en/docs/download/download.md index a23907c5c9..46e5f31038 100644 --- a/content/en/docs/download/download.md +++ b/content/en/docs/download/download.md @@ -2,6 +2,8 @@ title: "Download Apache HugeGraph" linkTitle: "Download" weight: 2 +search_keywords: [HugeGraph download, release artifacts, SHA512] +search_boost: 3 --- @@ -12,25 +14,11 @@ weight: 2 > - Instructions for checking hash (SHA512) and signatures are on the [Validate Release](/docs/contribution-guidelines/validate-release) page, and you can also refer to [ASF official instructions](https://www.apache.org/dyn/closer.cgi#verify). > - Note: The version numbers of all components of HugeGraph have been kept consistent, and the version numbers of Maven repositories such as `client/loader/hubble/common` are the same. You can refer to these for dependency references [maven example](https://github.com/apache/hugegraph-toolchain#maven-dependencies). > - Compatibility note: after HugeGraph graduated in January 2026, download paths moved from `/incubator/hugegraph` to `/hugegraph`. Historical release file names may still include `-incubating-`. +> - To build from source, refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server). -### Latest Version 1.7.0 +### Latest Version -- Release Date: 2025-11-28 -- [Release Notes](/docs/changelog/hugegraph-1.7.0-release-notes) - -#### Binary Packages - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0.tar.gz.sha512)] | - -#### Source Packages - -Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server). - -| Server | Toolchain | AI | Computer | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-ai-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-ai-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-ai-incubating-1.7.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.7.0/apache-hugegraph-computer-incubating-1.7.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-computer-incubating-1.7.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-computer-incubating-1.7.0-src.tar.gz.sha512)] | +{{< asf-downloads latest >}} --- @@ -38,70 +26,4 @@ Please refer to [build from source](/docs/quickstart/hugegraph/hugegraph-server) > Note: `1.3.0` is the last major version compatible with Java8, please switch to or migrate to Java11 as soon as possible (lower versions of Java have potentially more SEC risks and performance impacts). Starting from version `1.5.0`, a Java11 runtime environment is required. -#### 1.5.0 - -- Release Date: 2024-12-10 -- [Release Notes](/docs/changelog/hugegraph-1.5.0-release-notes) - -##### Binary Packages - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0.tar.gz.sha512)] | - -##### Source Packages - -| Server | Toolchain | AI | Computer | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-ai-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-ai-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-ai-incubating-1.5.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.5.0/apache-hugegraph-computer-incubating-1.5.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-computer-incubating-1.5.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.5.0/apache-hugegraph-computer-incubating-1.5.0-src.tar.gz.sha512)] | - -#### 1.3.0 - -- Release Date: 2024-04-01 -- [Release Notes](/docs/changelog/hugegraph-1.3.0-release-notes) - -##### Binary Packages - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0.tar.gz.sha512)] | - -##### Source Packages - -| Server | Toolchain | AI | Common | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-ai-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-ai-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-ai-incubating-1.3.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.3.0/apache-hugegraph-commons-incubating-1.3.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-commons-incubating-1.3.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.3.0/apache-hugegraph-commons-incubating-1.3.0-src.tar.gz.sha512)] | - -#### 1.2.0 - -- Release Date: 2023-12-28 -- [Release Notes](/docs/changelog/hugegraph-1.2.0-release-notes) - -##### Binary Packages - -| Server | Toolchain | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0.tar.gz.sha512)] | - -##### Source Packages - -| Server | Toolchain | Computer | Common | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-computer-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-computer-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-computer-incubating-1.2.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.2.0/apache-hugegraph-commons-incubating-1.2.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-commons-incubating-1.2.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.2.0/apache-hugegraph-commons-incubating-1.2.0-src.tar.gz.sha512)] | - -#### 1.0.0 - -- Release Date: 2023-02-22 -- [Release Notes](/docs/changelog/hugegraph-1.0.0-release-notes) - -##### Binary Packages - -| Server | Toolchain | Computer | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0.tar.gz.sha512)] | [[Binary](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0.tar.gz.sha512)] | - -##### Source Packages - -| Server | Toolchain | Computer | Common | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-computer-incubating-1.0.0-src.tar.gz.sha512)] | [[Source](https://www.apache.org/dyn/closer.lua/hugegraph/1.0.0/apache-hugegraph-commons-incubating-1.0.0-src.tar.gz?action=download)] [[Sign](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-commons-incubating-1.0.0-src.tar.gz.asc)] [[SHA512](https://downloads.apache.org/hugegraph/1.0.0/apache-hugegraph-commons-incubating-1.0.0-src.tar.gz.sha512)] | +{{< asf-downloads archived >}} diff --git a/content/en/docs/introduction/_index.md b/content/en/docs/introduction/_index.md index e83b690e18..2a01045b37 100644 --- a/content/en/docs/introduction/_index.md +++ b/content/en/docs/introduction/_index.md @@ -2,6 +2,8 @@ title: "Apache HugeGraph Introduction" linkTitle: "System Introduction" weight: 1 +search_keywords: [HugeGraph overview, graph database introduction, architecture] +search_boost: 3 aliases: # Hugo 0.165 prefixes aliases with the current language path. - /docs/introduction/readme/ @@ -111,7 +113,7 @@ Graph computing is an OLAP workload. Its capacity and resource requirements depe | Run graph algorithms | [Vermeer and Computer](/docs/quickstart/computing/) | | Build GraphRAG or graph machine learning applications | [HugeGraph-AI](/docs/quickstart/hugegraph-ai/) | -## Community +## Community {#community} - [GitHub Issues](https://github.com/apache/hugegraph/issues) - Developer mailing list: [dev@hugegraph.apache.org](mailto:dev@hugegraph.apache.org) diff --git a/content/en/docs/quickstart/computing/hugegraph-computer.md b/content/en/docs/quickstart/computing/hugegraph-computer.md index a153e6b437..8c445841e4 100644 --- a/content/en/docs/quickstart/computing/hugegraph-computer.md +++ b/content/en/docs/quickstart/computing/hugegraph-computer.md @@ -2,6 +2,8 @@ title: "HugeGraph-Computer Quick Start" linkTitle: "Analysis with HugeGraph-Computer" weight: 2 +search_keywords: [HugeGraph Computer, graph computing, OLAP] +search_boost: 1.6 --- ## 1 HugeGraph-Computer Overview diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md b/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md index cabd39d74d..39dc6ee699 100644 --- a/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md +++ b/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md @@ -3,10 +3,12 @@ title: "HugeGraph-Store Quick Start" linkTitle: "Install/Build HugeGraph-Store" weight: 3 search_keywords: - - server.port + - HugeGraph HStore + - distributed storage - REST port - Store REST port -search_boost: 1.5 + - server.port +search_boost: 1.6 --- ### 1 HugeGraph-Store Overview diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-pd.md b/content/en/docs/quickstart/hugegraph/hugegraph-pd.md index 2a8cefd7a8..8a5d56afa3 100644 --- a/content/en/docs/quickstart/hugegraph/hugegraph-pd.md +++ b/content/en/docs/quickstart/hugegraph/hugegraph-pd.md @@ -2,6 +2,8 @@ title: "HugeGraph-PD Quick Start" linkTitle: "Install/Build HugeGraph-PD" weight: 2 +search_keywords: [HugeGraph PD, placement driver, cluster metadata] +search_boost: 1.6 --- ### 1 HugeGraph-PD Overview diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-server.md b/content/en/docs/quickstart/hugegraph/hugegraph-server.md index 5288908f5f..ef0b0f39d5 100644 --- a/content/en/docs/quickstart/hugegraph/hugegraph-server.md +++ b/content/en/docs/quickstart/hugegraph/hugegraph-server.md @@ -2,6 +2,8 @@ title: "HugeGraph Server Quick Start" linkTitle: "Install/Build HugeGraph Server" weight: 1 +search_keywords: [HugeGraph Server, server quickstart, graph database] +search_boost: 1.7 aliases: - /docs/quickstart/hugegraph-server/ --- @@ -34,10 +36,11 @@ The `hugegraph-server` module in HugeGraph 1.7.0 is compiled with Java 11. Runni There are four ways to deploy the Server service: -- Method 1: Use Docker container (Convenient for Test/Dev) -- Method 2: Download the binary tarball -- Method 3: Source code compilation -- Method 4: One-click deployment +1. Use a Docker container for test or development. +1. Download the binary tarball. +1. Compile the source code. +1. Use the legacy one-click deployment tool. +{.steps} > Do not expose Gremlin, Cypher, or other query endpoints directly to the public Internet. In production, enable [authentication and authorization](/docs/config/config-authentication/), restrict network access, and retain audit logs. See the [Security Guide](/docs/guides/security/) for deployment guidance. @@ -70,7 +73,7 @@ Four compose files are available in the [`docker/`](https://github.com/apache/hu | HA reference | `docker-compose-3pd-3store-3server.yml` | 3 PD + 3 Store + 3 Server + 1 Hubble | | Source build override for the minimal HStore topology | `docker-compose.dev.yml` | (used together with `docker-compose-hstore.yml`) | -```bash +```bash {wrap=true} cd hugegraph/docker # Keep the version aligned with the latest release, for example 1.x.0 HUGEGRAPH_VERSION=1.7.0 docker compose -f docker-compose.yml up -d --wait @@ -91,7 +94,7 @@ See [docker/README.md](https://github.com/apache/hugegraph/blob/master/docker/RE ### 3.2 Download the binary tarball You could download the binary tarball from the download page of the ASF site like this: -```bash +```bash {filename="download-and-verify.sh" wrap=true collapse=5} # 1.7.0 is a historical release from the incubation period, so its file name still includes "incubating" wget https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz tar zxf apache-hugegraph-incubating-1.7.0.tar.gz @@ -109,7 +112,7 @@ Download HugeGraph **source code** in either of the following 2 ways (so as the - download the stable/release version from the ASF site - clone the unstable/latest version by GitBox(ASF) or GitHub -```bash +```bash {filename="build-from-source.sh" wrap=true collapse=5} # Way 1. download release package from the ASF site wget https://downloads.apache.org/hugegraph/{version}/apache-hugegraph-incubating-src-{version}.tar.gz tar zxf *hugegraph*.tar.gz diff --git a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md index da7bb23e62..fcac006c69 100644 --- a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md +++ b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md @@ -1,7 +1,10 @@ --- title: "HugeGraph-Hubble Quick Start" +description: "Deploy HugeGraph-Hubble for graph visualization, schema management, data import, and Gremlin or Cypher queries." linkTitle: "Visual with HugeGraph-Hubble" weight: 1 +search_keywords: [HugeGraph Hubble, graph visualization, web console] +search_boost: 1.6 --- ### 1 HugeGraph-Hubble Overview diff --git a/content/en/docs/quickstart/toolchain/hugegraph-loader.md b/content/en/docs/quickstart/toolchain/hugegraph-loader.md index 5cf06bd120..ce448b5a7f 100644 --- a/content/en/docs/quickstart/toolchain/hugegraph-loader.md +++ b/content/en/docs/quickstart/toolchain/hugegraph-loader.md @@ -2,6 +2,8 @@ title: "HugeGraph-Loader Quick Start" linkTitle: "Load data with HugeGraph-Loader" weight: 2 +search_keywords: [HugeGraph Loader, bulk import, data loading] +search_boost: 1.6 --- ### 1 HugeGraph-Loader Overview diff --git a/data/community/github-map.json b/data/community/github-map.json new file mode 100644 index 0000000000..5676d57ea1 --- /dev/null +++ b/data/community/github-map.json @@ -0,0 +1,92 @@ +{ + "schema_version": 1, + "public_names": { + "jin": "Imba Jin", + "jermy": "Jermy Li", + "jsong010123": "Jason", + "ningjiang": "Willem Ning Jiang", + "spica": "Thespica", + "yangjiaqi": "Jacky Yang" + }, + "display_order": { + "pmc": ["jin", "zhaocong", "lidongdai", "liyu"] + }, + "mappings": { + "guoshoujing": { + "login": "corgiboygsj", + "user_id": 24729637 + }, + "hxd": { + "login": "jixuan1989", + "user_id": 1021782 + }, + "jin": { + "login": "imbajin", + "user_id": 17706099 + }, + "jermy": { + "login": "javeme", + "user_id": 9625821 + }, + "jsong010123": { + "login": "MrJs133", + "user_id": 102796027 + }, + "leizou": { + "login": "z7658329", + "user_id": 5474723 + }, + "linary": { + "login": "Linary", + "user_id": 9151831 + }, + "liuxiaocs": { + "login": "liuxiaocs7", + "user_id": 42756849 + }, + "lidongdai": { + "login": "davidzollo", + "user_id": 15833811 + }, + "liyu": { + "login": "carp84", + "user_id": 6239804 + }, + "ming": { + "login": "simon824", + "user_id": 18065113 + }, + "pengjunzhi": { + "login": "Pengzna", + "user_id": 78788603 + }, + "vgalaxies": { + "login": "VGalaxies", + "user_id": 79143929 + }, + "vaughn": { + "login": "zyxxoo", + "user_id": 11863049 + }, + "yangjiaqi": { + "login": "JackyYangPassion", + "user_id": 13795366 + }, + "wangjing": { + "login": "wanganjuan", + "user_id": 10061479 + }, + "ningjiang": { + "login": "WillemJiang", + "user_id": 219644 + }, + "panjuan": { + "login": "tristaZero", + "user_id": 27757146 + }, + "zhaocong": { + "login": "coderzc", + "user_id": 26179648 + } + } +} diff --git a/data/community/roster.json b/data/community/roster.json new file mode 100644 index 0000000000..1bf64cecb3 --- /dev/null +++ b/data/community/roster.json @@ -0,0 +1,304 @@ +{ + "schema_version": 1, + "project": "hugegraph", + "retrieved_at": "2026-09-18T18:07:38Z", + "source": { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", + "chair": "jermy", + "owners": [ + "hxd", + "jermy", + "jin", + "lidongdai", + "linary", + "liyu", + "ming", + "ningjiang", + "panjuan", + "vaughn", + "vgalaxies", + "zhaocong" + ], + "members": [ + "guoshoujing", + "hxd", + "jermy", + "jin", + "jsong010123", + "leizou", + "lidongdai", + "linary", + "liuxiaocs", + "liyu", + "ming", + "ningjiang", + "panjuan", + "pengjunzhi", + "spica", + "vaughn", + "vgalaxies", + "vichayturen", + "wangjing", + "yangjiaqi", + "zhangyi89817", + "zhaocong" + ] + }, + "roles": { + "pmc": [ + { + "asf_id": "jermy", + "name": "Jermy Li", + "initials": "JL", + "chair": true, + "profile_url": "https://github.com/javeme", + "github": { + "login": "javeme", + "user_id": 9625821 + }, + "avatar": "/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp" + }, + { + "asf_id": "jin", + "name": "Imba Jin", + "initials": "IJ", + "chair": false, + "profile_url": "https://github.com/imbajin", + "github": { + "login": "imbajin", + "user_id": 17706099 + }, + "avatar": "/img/community/avatars/d3ecd2b51f116bece9a9c4cd6b6df651b1459206d2973b8bbb5bd302015d1a14.webp" + }, + { + "asf_id": "zhaocong", + "name": "coderzc", + "initials": "C", + "chair": false, + "profile_url": "https://github.com/coderzc", + "github": { + "login": "coderzc", + "user_id": 26179648 + }, + "avatar": "/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp" + }, + { + "asf_id": "lidongdai", + "name": "davidzollo", + "initials": "D", + "chair": false, + "profile_url": "https://github.com/davidzollo", + "github": { + "login": "davidzollo", + "user_id": 15833811 + }, + "avatar": "/img/community/avatars/6962957beb238414dd798740aada61a514377c349d8fed86f091792b303e76e0.webp" + }, + { + "asf_id": "liyu", + "name": "carp84", + "initials": "C", + "chair": false, + "profile_url": "https://github.com/carp84", + "github": { + "login": "carp84", + "user_id": 6239804 + }, + "avatar": "/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp" + }, + { + "asf_id": "hxd", + "name": "jixuan1989", + "initials": "J", + "chair": false, + "profile_url": "https://github.com/jixuan1989", + "github": { + "login": "jixuan1989", + "user_id": 1021782 + }, + "avatar": "/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp" + }, + { + "asf_id": "linary", + "name": "Linary", + "initials": "L", + "chair": false, + "profile_url": "https://github.com/Linary", + "github": { + "login": "Linary", + "user_id": 9151831 + }, + "avatar": "/img/community/avatars/695bd7904d7f4cfab98b7d812c769f481b73cf9d8f1d6d985e865d87e633e36a.webp" + }, + { + "asf_id": "ming", + "name": "simon824", + "initials": "S", + "chair": false, + "profile_url": "https://github.com/simon824", + "github": { + "login": "simon824", + "user_id": 18065113 + }, + "avatar": "/img/community/avatars/c936571abaf109fd7049f976c1767bc21d05f4a7ed018bc232cdc0c83cbf2346.webp" + }, + { + "asf_id": "panjuan", + "name": "tristaZero", + "initials": "T", + "chair": false, + "profile_url": "https://github.com/tristaZero", + "github": { + "login": "tristaZero", + "user_id": 27757146 + }, + "avatar": "/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp" + }, + { + "asf_id": "vgalaxies", + "name": "VGalaxies", + "initials": "V", + "chair": false, + "profile_url": "https://github.com/VGalaxies", + "github": { + "login": "VGalaxies", + "user_id": 79143929 + }, + "avatar": "/img/community/avatars/657d7c6a76e43a96d9da26ab4eab361172ebb3dae217a71f79b7531a7637abdc.webp" + }, + { + "asf_id": "ningjiang", + "name": "Willem Ning Jiang", + "initials": "WN", + "chair": false, + "profile_url": "https://github.com/WillemJiang", + "github": { + "login": "WillemJiang", + "user_id": 219644 + }, + "avatar": "/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp" + }, + { + "asf_id": "vaughn", + "name": "zyxxoo", + "initials": "Z", + "chair": false, + "profile_url": "https://github.com/zyxxoo", + "github": { + "login": "zyxxoo", + "user_id": 11863049 + }, + "avatar": "/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp" + } + ], + "committers": [ + { + "asf_id": "guoshoujing", + "name": "corgiboygsj", + "initials": "C", + "chair": false, + "profile_url": "https://github.com/corgiboygsj", + "github": { + "login": "corgiboygsj", + "user_id": 24729637 + }, + "avatar": "/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp" + }, + { + "asf_id": "yangjiaqi", + "name": "Jacky Yang", + "initials": "JY", + "chair": false, + "profile_url": "https://github.com/JackyYangPassion", + "github": { + "login": "JackyYangPassion", + "user_id": 13795366 + }, + "avatar": "/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp" + }, + { + "asf_id": "jsong010123", + "name": "Jason", + "initials": "J", + "chair": false, + "profile_url": "https://github.com/MrJs133", + "github": { + "login": "MrJs133", + "user_id": 102796027 + }, + "avatar": "/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp" + }, + { + "asf_id": "vichayturen", + "name": "Kaiyichen Wei", + "initials": "KW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vichayturen" + }, + { + "asf_id": "liuxiaocs", + "name": "liuxiaocs7", + "initials": "L", + "chair": false, + "profile_url": "https://github.com/liuxiaocs7", + "github": { + "login": "liuxiaocs7", + "user_id": 42756849 + }, + "avatar": "/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp" + }, + { + "asf_id": "pengjunzhi", + "name": "Pengzna", + "initials": "P", + "chair": false, + "profile_url": "https://github.com/Pengzna", + "github": { + "login": "Pengzna", + "user_id": 78788603 + }, + "avatar": "/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp" + }, + { + "asf_id": "spica", + "name": "Thespica", + "initials": "T", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=spica" + }, + { + "asf_id": "wangjing", + "name": "wanganjuan", + "initials": "W", + "chair": false, + "profile_url": "https://github.com/wanganjuan", + "github": { + "login": "wanganjuan", + "user_id": 10061479 + }, + "avatar": "/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp" + }, + { + "asf_id": "zhangyi89817", + "name": "Yi Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhangyi89817" + }, + { + "asf_id": "leizou", + "name": "z7658329", + "initials": "Z", + "chair": false, + "profile_url": "https://github.com/z7658329", + "github": { + "login": "z7658329", + "user_id": 5474723 + }, + "avatar": "/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp" + } + ] + } +} diff --git a/data/downloads/asf.json b/data/downloads/asf.json new file mode 100644 index 0000000000..af81244a6f --- /dev/null +++ b/data/downloads/asf.json @@ -0,0 +1,53 @@ +{ + "$comment": "Single source of truth for the ASF release download facts. Both language download pages render from this file through layouts/_partials/asf-downloads.html; do not edit the page tables by hand. Every filename was verified against https://downloads.apache.org/hugegraph// on 2026-09-13. file = [-incubating]-[-src].tar.gz where -incubating applies when the release has incubating: true; mirror = https://www.apache.org/dyn/closer.lua///?action=download ; asc/sha512 = https://downloads.apache.org///.asc|.sha512", + "dist_path": "hugegraph", + "components": { + "server": { "prefix": "apache-hugegraph", "label_key": "download_component_server" }, + "toolchain": { "prefix": "apache-hugegraph-toolchain", "label_key": "download_component_toolchain" }, + "ai": { "prefix": "apache-hugegraph-ai", "label_key": "download_component_ai" }, + "computer": { "prefix": "apache-hugegraph-computer", "label_key": "download_component_computer" }, + "commons": { "prefix": "apache-hugegraph-commons", "label_key": "download_component_commons" } + }, + "releases": [ + { + "version": "1.7.0", + "date": "2025-11-28", + "latest": true, + "incubating": true, + "binary": ["server", "toolchain"], + "source": ["server", "toolchain", "ai", "computer"] + }, + { + "version": "1.5.0", + "date": "2024-12-10", + "latest": false, + "incubating": true, + "binary": ["server", "toolchain"], + "source": ["server", "toolchain", "ai", "computer"] + }, + { + "version": "1.3.0", + "date": "2024-04-01", + "latest": false, + "incubating": true, + "binary": ["server", "toolchain"], + "source": ["server", "toolchain", "ai", "commons"] + }, + { + "version": "1.2.0", + "date": "2023-12-28", + "latest": false, + "incubating": true, + "binary": ["server", "toolchain"], + "source": ["server", "toolchain", "computer", "commons"] + }, + { + "version": "1.0.0", + "date": "2023-02-22", + "latest": false, + "incubating": true, + "binary": ["server", "toolchain", "computer"], + "source": ["server", "toolchain", "computer", "commons"] + } + ] +} diff --git a/data/home/cn.yaml b/data/home/cn.yaml index 8479292614..7b94a27fd2 100644 --- a/data/home/cn.yaml +++ b/data/home/cn.yaml @@ -56,7 +56,8 @@ community: items: - title: 使用易用的**工具链** icon: fa-solid fa-screwdriver-wrench - desc: 可从[此](https://github.com/apache/hugegraph-toolchain)获取图数据导入工具, 可视化界面以及备份还原迁移工具, 欢迎使用 + url: /cn/docs/quickstart/toolchain/ + desc: 获取图数据导入工具、可视化界面以及备份还原迁移工具,欢迎使用。 - title: 参与开源 icon: fa-brands fa-github url: https://github.com/apache/hugegraph @@ -64,10 +65,11 @@ community: desc: 我们可以在 **Github** 上提交 [Pull Request](https://github.com/apache/hugegraph/pulls). 热烈欢迎大家加入! - title: 关注微信 icon: fa-brands fa-weixin + url: /cn/docs/introduction/#community desc: |- 关注微信公众号 "HugeGraph" 获取最新动态 - 也可以加入 [ASF Slack 频道](https://the-asf.slack.com/archives/C059UU2FJ23)参与讨论 + 也可以加入 [ASF Slack 频道](https://the-asf.slack.com/archives/C059UU2FJ23)参与社区讨论 welcome: class: hg-home-band hg-home-welcome diff --git a/data/home/en.yaml b/data/home/en.yaml index 2ddded8504..64c0c18019 100644 --- a/data/home/en.yaml +++ b/data/home/en.yaml @@ -56,7 +56,8 @@ community: items: - title: Get The **Toolchain** icon: fa-solid fa-screwdriver-wrench - desc: '[It](https://github.com/apache/hugegraph-toolchain) includes graph loader & dashboard & backup tools' + url: /docs/quickstart/toolchain/ + desc: Explore graph loading, visualization, backup, restore, and migration tools. - title: Efficient icon: fa-brands fa-github url: https://github.com/apache/hugegraph @@ -69,8 +70,6 @@ community: desc: |- Join the [ASF Slack channel](https://the-asf.slack.com/archives/C059UU2FJ23) for community discussions - Could also follow the WeChat account "HugeGraph" for updates - welcome: class: hg-home-band hg-home-welcome title: Welcome to the HugeGraph open source community! diff --git a/data/landing/community/cn.yaml b/data/landing/community/cn.yaml index 9bf9f66f4c..3095009dee 100644 --- a/data/landing/community/cn.yaml +++ b/data/landing/community/cn.yaml @@ -8,6 +8,11 @@ sections: - { label: 参与贡献, url: /docs/contribution-guidelines/, icon: fa-solid fa-code-pull-request, style: primary } - { label: 前往 GitHub, url: 'https://github.com/apache/hugegraph', icon: fa-brands fa-github, external: true, style: ghost } + - type: community-members + partial: landing/sections/community-members.html + data: + id: project-members + - type: cards data: title: 参与社区 @@ -30,9 +35,9 @@ sections: - [GitHub](https://github.com/apache/hugegraph) — Apache HugeGraph 的开发在各项目仓库中公开进行。 - [开发者邮件列表](/cn/docs/contribution-guidelines/subscribe/) — 讨论项目开发和社区事务。 - - [安全邮件列表](mailto:security@hugegraph.apache.org) — 私下报告安全问题。 - - [安全策略](/cn/docs/guides/security/) — 了解项目的安全问题报告流程。 + - [安全报告](mailto:security@hugegraph.apache.org) 与 [安全策略](/cn/docs/guides/security/) — 私下报告安全问题并了解处理流程。 - [贡献指南](/cn/docs/contribution-guidelines/) — 了解如何贡献代码和文档。 + - [Committer 资源](https://infra.apache.org/committers.html) — 了解面向 Apache Committers 的 ASF 工具、服务与指引。 - type: markdown data: diff --git a/data/landing/community/en.yaml b/data/landing/community/en.yaml index 9f54604f72..4f5ae5e52b 100644 --- a/data/landing/community/en.yaml +++ b/data/landing/community/en.yaml @@ -8,6 +8,11 @@ sections: - { label: Contribute, url: /docs/contribution-guidelines/, icon: fa-solid fa-code-pull-request, style: primary } - { label: View on GitHub, url: 'https://github.com/apache/hugegraph', icon: fa-brands fa-github, external: true, style: ghost } + - type: community-members + partial: landing/sections/community-members.html + data: + id: project-members + - type: cards data: title: Get involved @@ -30,9 +35,9 @@ sections: - [GitHub](https://github.com/apache/hugegraph) — development takes place in the Apache HugeGraph repositories. - [Developer mailing list](/docs/contribution-guidelines/subscribe/) — discuss project and community topics. - - [Security mailing list](mailto:security@hugegraph.apache.org) — report security issues privately. - - [Security policy](/docs/guides/security/) — follow the project's security reporting process. + - [Security reporting](mailto:security@hugegraph.apache.org) and [security policy](/docs/guides/security/) — report issues and follow the project's process. - [Contribution guidelines](/docs/contribution-guidelines/) — learn how to contribute code and documentation. + - [Committer resources](https://infra.apache.org/committers.html) — ASF tools, services, and guidance for project committers. - type: cta data: diff --git a/dist/validate-site-output.py b/dist/validate-site-output.py index 28a5b82e16..4b9894037c 100644 --- a/dist/validate-site-output.py +++ b/dist/validate-site-output.py @@ -74,7 +74,7 @@ } DOCS_NAV_GROUP_TITLES = { "en": ("Get Started", "Components", "Develop", "Operate", "Reference"), - "cn": ("开始", "组件", "开发", "运维", "参考"), + "cn": ("开始", "组件", "开发", "配置", "参考"), } EXTERNAL_ACTIVE_RESOURCE_ATTRIBUTES = { ("script", "src"), diff --git a/hugo.yaml b/hugo.yaml index d276b93c56..93a313c006 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -45,7 +45,7 @@ languages: - { 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-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 } diff --git a/i18n/en.yaml b/i18n/en.yaml index 4c0e1c29af..2544995466 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -2,5 +2,27 @@ ui_version_fallback: This page is not available in the selected version. You hav ui_ask_ai: Ask AI ui_ask_ai_description: Powered by Kapa; only your question is sent. ui_ask_ai_latest: Answers use the latest documentation +ui_ask_ai_no_results: No results found. Try AI mode. ui_retry: Retry ui_ai_error: AI is temporarily unavailable. Local search is unaffected. +ui_more_versions: More versions +download_release_version: Version +download_release_date: Release Date +download_release_notes: Release Notes +download_table_component: Component +download_table_type: Type +download_table_mirror: Download (ASF mirror) +download_type_binary: Binary +download_type_source: Source +download_component_server: Server +download_component_toolchain: Toolchain +download_component_ai: AI +download_component_computer: Computer +download_component_commons: Common +download_asf_note: Source archives are the official Apache Software Foundation releases; binary packages are convenience builds made from them. All files are served from ASF mirrors, with signatures and checksums hosted on downloads.apache.org. Source archives generated automatically by GitHub are not ASF releases. +ui_assets_download: Download asset + +ui_ai_consent_description: "Continuing loads Kapa, a third-party service. Your question will be sent to Kapa to generate an answer." +ui_ai_consent_continue: "Continue to Ask AI" +ui_ai_consent_cancel: "Cancel" +ui_ai_privacy: "Privacy policy" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index f173ec727e..f373dc0d91 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -238,5 +238,26 @@ ui_version_fallback: 目标版本没有此页面,已转到该版本的文档 ui_ask_ai: 询问 AI ui_ask_ai_description: 由 Kapa 提供;仅发送你的问题。 ui_ask_ai_latest: 回答基于 latest 文档 +ui_ask_ai_no_results: 未找到结果,可以试试 Ask AI 模式。 ui_retry: 重试 ui_ai_error: AI 暂时不可用,本地搜索不受影响。 +ui_more_versions: 更多版本 +download_release_version: 版本 +download_release_date: 发布日期 +download_release_notes: 发布说明 +download_table_component: 组件 +download_table_type: 类型 +download_table_mirror: 下载 (ASF 镜像) +download_type_binary: 二进制 +download_type_source: 源码 +download_component_server: Server +download_component_toolchain: Toolchain +download_component_ai: AI +download_component_computer: Computer +download_component_commons: Common +download_asf_note: 源码归档是 Apache 软件基金会的正式发布物;二进制软件包是基于源码构建的便利用品。所有文件均通过 ASF 镜像分发,签名与校验和托管在 downloads.apache.org。GitHub 自动生成的源码归档不是 ASF 正式发布版本。 + +ui_ai_consent_description: "继续后将加载第三方服务 Kapa,并将你的问题发送给 Kapa 生成回答。" +ui_ai_consent_continue: "继续使用 Ask AI" +ui_ai_consent_cancel: "取消" +ui_ai_privacy: "隐私政策" diff --git a/layouts/_partials/asf-downloads.html b/layouts/_partials/asf-downloads.html new file mode 100644 index 0000000000..e561159488 --- /dev/null +++ b/layouts/_partials/asf-downloads.html @@ -0,0 +1,104 @@ +{{- /* Render ASF release download tables from data/downloads/asf.json. + + The data file is the single source of truth for both languages; this + partial only derives presentation. Malformed site-owned data fails the + build via errorf so a broken table can never publish silently. + + mode: "latest" renders the one latest release, "archived" renders the + rest. The mode is required so a page cannot render duplicate sections + (and duplicate element ids) by accident. */ -}} +{{- $page := .page -}} +{{- $mode := .mode -}} +{{- $position := .position | default $page.Path -}} +{{- if not (in (slice "latest" "archived") $mode) -}} + {{- errorf "%s: asf-downloads mode must be latest or archived; got %q" $position $mode -}} +{{- end -}} +{{- $data := index (index hugo.Data "downloads" | default dict) "asf" | default dict -}} +{{- $dist := $data.dist_path | default "" -}} +{{- $components := $data.components | default dict -}} +{{- $releases := $data.releases | default slice -}} +{{- if not (and $dist $components $releases) -}} + {{- errorf "%s: data/downloads/asf.json must define dist_path, components, and releases" $position -}} +{{- end -}} +{{- $selected := slice -}} +{{- range $releases -}} + {{- $isLatest := .latest | default false -}} + {{- if or (and (eq $mode "latest") $isLatest) (and (eq $mode "archived") (not $isLatest)) -}} + {{- $selected = $selected | append . -}} + {{- end -}} +{{- end -}} +{{- if and (eq $mode "latest") (not $selected) -}} + {{- errorf "%s: asf-downloads latest mode requires one release marked latest" $position -}} +{{- end -}} +{{- range $release := $selected -}} +{{- $version := $release.version -}} +{{- if not $version -}}{{- errorf "%s: asf-downloads release without a version" $position -}}{{- end -}} +{{- $infix := cond ($release.incubating | default false) "-incubating" "" -}} +{{- $rows := slice -}} +{{- range $kind := slice "binary" "source" -}} + {{- range $componentID := index $release $kind | default slice -}} + {{- $component := index $components $componentID -}} + {{- if not $component -}} + {{- errorf "%s: asf-downloads release %s references unknown component %q" $position $version $componentID -}} + {{- end -}} + {{- if not (and $component.prefix $component.label_key) -}} + {{- errorf "%s: asf-downloads component %q must define prefix and label_key" $position $componentID -}} + {{- end -}} + {{- $suffix := cond (eq $kind "source") "-src" "" -}} + {{- $file := printf "%s%s-%s%s.tar.gz" $component.prefix $infix $version $suffix -}} + {{- $rows = $rows | append (dict + "label" (T $component.label_key) + "kind" $kind + "file" $file + "mirror" (printf "https://www.apache.org/dyn/closer.lua/%s/%s/%s?action=download" $dist $version $file) + "asc" (printf "https://downloads.apache.org/%s/%s/%s.asc" $dist $version $file) + "sha512" (printf "https://downloads.apache.org/%s/%s/%s.sha512" $dist $version $file) + ) -}} + {{- end -}} +{{- end -}} +{{- if not $rows -}}{{- errorf "%s: asf-downloads release %s has no artifacts" $position $version -}}{{- end -}} +
+ {{- if ne $mode "latest" }} +

{{ $version }}

+ {{- end }} + +
+
+ + + + + + + + + + + + + {{- range $row := $rows }} + + + + + + + + {{- end }} + +
{{ T "ui_assets_download" }} · Apache HugeGraph {{ $version }}
{{ T "download_table_component" }}{{ T "download_table_type" }}{{ T "download_table_mirror" }}ASCSHA512
{{ $row.label }}{{ T (printf "download_type_%s" $row.kind) }} + {{ $row.file }} + ASCSHA512
+
+
+
+{{- end -}} +{{- if eq $mode "latest" }} +

{{ T "download_asf_note" }}

+{{- end -}} diff --git a/layouts/_partials/community/members.html b/layouts/_partials/community/members.html new file mode 100644 index 0000000000..9dff73fe7e --- /dev/null +++ b/layouts/_partials/community/members.html @@ -0,0 +1,41 @@ +{{- $page := .page -}} +{{- $data := hugo.Data.community.roster -}} +{{- $labels := dict + "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from ASF records." "guide" "(Note: How to become a HugeGraph Committer)" "guideNote" "" "chair" "Chair" "pmc" "PMC" "committers" "Committers" "github" "on GitHub") + "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 记录。" "guide" "(注: 如何成为 HugeGraph Committer)" "guideNote" "" "chair" "主席" "pmc" "PMC" "committers" "Committers" "github" "的 GitHub 主页") +-}} +{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}} +{{- $style := resources.Get "scss/community-members.scss" | toCSS | minify | fingerprint -}} + +
+
+
+

{{ $copy.title }}

+

{{ $copy.lead }} {{ $copy.guide }}{{ with $copy.guideNote }}{{ . }}{{ end }}

+
+ {{- range $role := slice "pmc" "committers" }} + {{- $members := index $data.roles $role }} +
+

{{ index $copy $role }}

+ +
+ {{- end }} +
+
diff --git a/layouts/_partials/community/members.md b/layouts/_partials/community/members.md new file mode 100644 index 0000000000..632a106664 --- /dev/null +++ b/layouts/_partials/community/members.md @@ -0,0 +1,26 @@ +{{- $page := .page -}} +{{- $data := hugo.Data.community.roster -}} +{{- $labels := dict + "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from ASF records. [(Note: How to become a HugeGraph Committer)](/docs/contribution-guidelines/committer-guidelines/)" "chair" "Chair") + "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 记录。[(注: 如何成为 HugeGraph Committer)](/cn/docs/contribution-guidelines/committer-guidelines/)" "chair" "主席") +-}} +{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}} +## {{ $copy.title }} + +{{ $copy.lead }} + +{{ range $role := slice "pmc" "committers" -}} +### {{ if eq $role "pmc" }}PMC{{ else }}Committers{{ end }} + +{{ range (index $data.roles $role) -}} +{{- $label := .name -}} +{{- $label = partial "content/markdown-escape.html" $label -}} +{{- if .github -}} +{{- $url := partial "content/markdown-url.html" .profile_url -}} +- [{{ $label }}]({{ $url }}) +{{- else -}} +- {{ $label }} +{{- end }} +{{ end }} + +{{ end -}} diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html index bca33d724a..fff6e6dd25 100644 --- a/layouts/_partials/hooks/body-end.html +++ b/layouts/_partials/hooks/body-end.html @@ -24,6 +24,7 @@ "ask" (T "ui_ask_ai") "description" (T "ui_ask_ai_description") "latest" (T "ui_ask_ai_latest") + "noResults" (T "ui_ask_ai_no_results") "retry" (T "ui_retry") "error" (T "ui_ai_error") -}} @@ -37,13 +38,20 @@ -}} -

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

+ + +

{{ T "ui_ai_consent_description" }}

+ {{ if $historical }}

{{ index $labels "latest" }}.

{{ end }} +

{{ T "ui_ai_privacy" }}

+ +
{{- $adapter := resources.Get "js/kapa-adapter.js" -}} {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint }}{{ end }} diff --git a/layouts/_partials/landing/sections/community-members.html b/layouts/_partials/landing/sections/community-members.html new file mode 100644 index 0000000000..2e1abd08b0 --- /dev/null +++ b/layouts/_partials/landing/sections/community-members.html @@ -0,0 +1 @@ +{{- partial "community/members.html" (dict "page" .page) -}} diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html index 0f7d8ab88c..9eeb35de01 100644 --- a/layouts/_partials/navbar.html +++ b/layouts/_partials/navbar.html @@ -77,23 +77,7 @@ {{- partialCached "shell/icon.html" "code-branch" "navbar-version-code-branch" -}}
- {{- range . }} - {{- if eq .name "---" }} -
- {{- else }} - {{- $url := strings.TrimSuffix "/" (.url | default "") }} - {{- $isActive := or (eq .version $.Site.Params.version) (eq (strings.TrimSuffix "/" $.Site.BaseURL) $url) }} - {{- if $url }} - {{- partial "version-link.html" (dict - "page" $ "version" . "active" $isActive "icon" false - "class" (printf "td-nav-hover-menu__option%s" (cond $isActive " td-is-active" "")) - "iconKey" (printf "navbar-version-%s" .version) - ) -}} - {{- else }} - {{ .name | default .version | markdownify }} - {{- end }} - {{- end }} - {{- end }} + {{ partial "version-menu-links.html" (dict "page" $ "versions" . "class" "td-nav-hover-menu__option" "icon" false "key" "navbar") }}
{{- end }} @@ -168,16 +152,7 @@ {{- else if and (eq .Identifier "docs") $.Site.Params.versions }}
- {{- range $.Site.Params.versions }} - {{- $url := strings.TrimSuffix "/" (.url | default "") -}} - {{- if $url }} - {{- partial "version-link.html" (dict - "page" $ "version" . "active" (eq .version $.Site.Params.version) - "class" "td-site-nav__menu-link td-site-nav__menu-child" - "iconKey" (printf "landing-mobile-version-%s" .version) - ) -}} - {{- end }} - {{- end }} + {{ partial "version-menu-links.html" (dict "page" $ "versions" $.Site.Params.versions "class" "td-site-nav__menu-link td-site-nav__menu-child" "key" "landing-mobile") }}
{{- end }} {{- end }} diff --git a/layouts/_partials/shell/sidebar-panel.html b/layouts/_partials/shell/sidebar-panel.html index a433197ba4..e4a57af25e 100644 --- a/layouts/_partials/shell/sidebar-panel.html +++ b/layouts/_partials/shell/sidebar-panel.html @@ -63,15 +63,7 @@
{{ $.Site.Params.version_menu | default "Version" }}
{{- end }} diff --git a/layouts/_partials/version-menu-links.html b/layouts/_partials/version-menu-links.html new file mode 100644 index 0000000000..6f3bf8117b --- /dev/null +++ b/layouts/_partials/version-menu-links.html @@ -0,0 +1,17 @@ +{{- /* Presentation only: all version targets remain in the document. Native + details provides pointer, touch and keyboard access without a JS dependency. */ -}} +{{- $p := .page -}} +{{- $links := where .versions "url" "ne" "" -}} +{{- range $index, $version := $links -}} + {{- if eq $index 3 }} +
+ … + {{- end }} + {{- $active := eq $version.version $p.Site.Params.version -}} + {{- partial "version-link.html" (dict + "page" $p "version" $version "active" $active "icon" $.icon + "class" (printf "%s%s" $.class (cond $active " td-is-active" "")) + "iconKey" (printf "%s-version-%s" $.key $version.version) + ) -}} +{{- end -}} +{{- if gt (len $links) 3 }}
{{ end -}} diff --git a/layouts/_shortcodes/asf-downloads.html b/layouts/_shortcodes/asf-downloads.html new file mode 100644 index 0000000000..f12b800481 --- /dev/null +++ b/layouts/_shortcodes/asf-downloads.html @@ -0,0 +1,8 @@ +{{- /* {{< asf-downloads latest >}} or {{< asf-downloads archived >}}: + ASF release download tables driven by data/downloads/asf.json. The + positional mode is required; a missing mode fails the build instead of + rendering duplicate sections. */ -}} +{{- if or .IsNamedParams (ne (len .Params) 1) -}} + {{- errorf "asf-downloads requires exactly one positional mode (latest or archived) at %s" .Position -}} +{{- end -}} +{{- partial "asf-downloads.html" (dict "page" .Page "mode" (.Get 0) "position" .Position) -}} diff --git a/layouts/community/landing.md b/layouts/community/landing.md new file mode 100644 index 0000000000..e884af8fe5 --- /dev/null +++ b/layouts/community/landing.md @@ -0,0 +1,29 @@ +{{- /* + Community Markdown follows the landing data order. Native OINK sections use + its text renderer unchanged; only the nested roster uses the site partial. +*/ -}} +{{- .Store.Set "tdOutputFormat" "markdown" -}} +# {{ .Title }} + +{{ with .Description }} +> {{ . }} + +{{ end }} + +{{ $page := . -}} +{{- $landing := partial "landing/data.html" . -}} +{{- $chunks := slice -}} +{{- range $entry := ($landing.sections | default slice) -}} + {{- $resolved := partial "landing/entry.html" (dict "home" $landing "entry" $entry) -}} + {{- if and $resolved.enabled $resolved.data -}} + {{- if eq $resolved.type "community-members" -}} + {{- $chunks = $chunks | append (partial "community/members.md" (dict "page" $page) | strings.TrimSpace) -}} + {{- else -}} + {{- $text := partial "landing/text.html" (dict "page" $page "data" (dict "sections" (slice $entry))) | strings.TrimSpace -}} + {{- with $text }} + {{- $chunks = $chunks | append . -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{ delimit $chunks "\n\n" | safeHTML }} diff --git a/scripts/community_roster.md b/scripts/community_roster.md new file mode 100644 index 0000000000..5b78afc3db --- /dev/null +++ b/scripts/community_roster.md @@ -0,0 +1,41 @@ +# Community roster data + +`roster.json` is the checked-in, visitor-facing snapshot of current Apache +HugeGraph PMC members and Committers. It is generated from the three ASF public +sources recorded in the file: + +```bash +python3 scripts/community_roster.py refresh +python3 scripts/community_roster.py validate --warn-after-days 90 +``` + +`refresh` is a maintainer-run operation. It finishes all source, role, mapping, +and avatar checks before atomically replacing the last-good roster. It never +pushes or opens a pull request. + +`github-map.json` is deliberately maintained by human review. A mapping must +record both the exact GitHub login and the account's numeric GitHub user ID. +Do not derive mappings from a person's name, email address, employer, or commit +history. Leave an ASF ID unmapped until a maintainer has confirmed the account. + +Mapped avatars are downloaded during refresh, converted with `cwebp` when +needed, stripped of metadata, checked as 128 by 128 WebP, and stored under a +SHA-256 content-addressed filename. Unmapped members render initials as static +cards without requiring JavaScript. + +The fixed `validate` command is fully offline and checks checked-in data, +identity rules, and local avatar files. Render the site separately, then opt in +to artifact checks: + +```bash +hugo --destination /safe/prebuilt/site +python3 scripts/community_roster.py validate \ + --warn-after-days 90 \ + --artifact /safe/prebuilt/site +``` + +The artifact option never invokes Hugo or downloads modules itself. + +Once the verified assets and roster are published, removal of an unreferenced +old avatar is best effort. A cleanup failure emits an Actions warning but does +not invalidate or roll back the complete new bundle. diff --git a/scripts/community_roster.py b/scripts/community_roster.py new file mode 100644 index 0000000000..c3c2ec883c --- /dev/null +++ b/scripts/community_roster.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 +"""Refresh and validate the offline Apache HugeGraph community roster.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import html.parser +import json +import os +import pathlib +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DATA_DIR = ROOT / "data" / "community" +ROSTER_PATH = DATA_DIR / "roster.json" +MAP_PATH = DATA_DIR / "github-map.json" +AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars" +PROJECT = "hugegraph" +SCHEMA_VERSION = 1 +SOURCES = { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", +} +JSON_LIMIT = 16 * 1024 * 1024 +AVATAR_LIMIT = 5 * 1024 * 1024 +ASF_ID_PATTERN = re.compile(r"^[a-z][a-z0-9._-]*$") +GITHUB_LOGIN_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") +AVATAR_PATH_PATTERN = re.compile(r"^/img/community/avatars/([0-9a-f]{64})\.webp$") + + +class RosterError(ValueError): + pass + + +def _read_json(path: pathlib.Path) -> dict: + try: + result = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RosterError(f"{path}: invalid JSON: {exc}") from exc + if not isinstance(result, dict): + raise RosterError(f"{path}: JSON root must be an object") + return result + + +def _validate_remote_url(url: str, expected_hosts: set[str], kind: str) -> None: + try: + parsed = urllib.parse.urlparse(url) + port = parsed.port + except ValueError as exc: + raise RosterError(f"{kind}: malformed URL: {url}") from exc + if ( + parsed.scheme != "https" + or parsed.hostname not in expected_hosts + or port not in (None, 443) + or parsed.username is not None + or parsed.password is not None + ): + raise RosterError(f"{kind}: URL is not allowlisted: {url}") + + +class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, expected_hosts: set[str], kind: str): + super().__init__() + self.expected_hosts = expected_hosts + self.kind = kind + + def redirect_request(self, request, fp, code, msg, headers, newurl): + _validate_remote_url(newurl, self.expected_hosts, self.kind) + return super().redirect_request(request, fp, code, msg, headers, newurl) + + +def _open_allowlisted(request: urllib.request.Request, expected_hosts: set[str], kind: str): + _validate_remote_url(request.full_url, expected_hosts, kind) + opener = urllib.request.build_opener(_AllowlistedRedirectHandler(expected_hosts, kind)) + return opener.open(request, timeout=30) + + +def _read_bounded_response(response, *, expected_hosts: set[str], content_types: set[str], limit: int, kind: str) -> bytes: + final_url = response.geturl() + _validate_remote_url(final_url, expected_hosts, kind) + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + if content_type not in content_types and not (kind == "JSON source" and content_type.endswith("+json")): + raise RosterError(f"{kind}: unsupported Content-Type {content_type!r}") + raw = response.read(limit + 1) + if len(raw) > limit: + raise RosterError(f"{kind}: response exceeds {limit} bytes") + return raw + + +def _fetch_json(url: str) -> dict: + request = urllib.request.Request(url, headers={"User-Agent": "apache-hugegraph-doc-community-roster/1"}) + with _open_allowlisted(request, {"whimsy.apache.org"}, "JSON source") as response: + if response.status != 200: + raise RosterError(f"{url}: HTTP {response.status}") + raw = _read_bounded_response( + response, + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=JSON_LIMIT, + kind="JSON source", + ) + try: + result = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RosterError(f"{url}: malformed JSON: {exc}") from exc + if not isinstance(result, dict): + raise RosterError(f"{url}: JSON root must be an object") + return result + + +def _person_name(people: dict, asf_id: str) -> str: + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"invalid ASF ID {asf_id!r}") + records = people.get("people") + if not isinstance(records, dict): + raise RosterError("people source must contain a people object") + record = records.get(asf_id) + name = record.get("name") if isinstance(record, dict) else None + if isinstance(name, list): + name = name[0] if name else "" + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"people source has no public name for ASF ID {asf_id!r}") + return name.strip() + + +def _initials(name: str) -> str: + parts = [part for part in name.replace("-", " ").split() if part] + return "".join(part[0].upper() for part in parts[:2]) or "?" + + +def _sort_key(asf_id: str, names: dict[str, str]) -> tuple[str, str]: + return names[asf_id].casefold(), asf_id.casefold() + + +def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict: + if data.get("schema_version") != SCHEMA_VERSION: + raise RosterError("github-map.json: schema_version must be 1") + mappings = data.get("mappings") + if not isinstance(mappings, dict): + raise RosterError("github-map.json: mappings must be an object") + public_names = data.get("public_names", {}) + if not isinstance(public_names, dict): + raise RosterError("github-map.json: public_names must be an object") + for asf_id, public_name in public_names.items(): + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"github-map.json: invalid public-name ASF ID {asf_id!r}") + if roster_ids is not None and asf_id not in roster_ids: + raise RosterError(f"github-map.json: unknown public-name ASF ID {asf_id!r}") + if not isinstance(public_name, str) or not public_name.strip(): + raise RosterError(f"github-map.json: public name for {asf_id!r} must be non-empty") + display_order = data.get("display_order", {}) + if not isinstance(display_order, dict): + raise RosterError("github-map.json: display_order must be an object") + for role, ordered_ids in display_order.items(): + if role not in {"pmc", "committers"} or not isinstance(ordered_ids, list): + raise RosterError("github-map.json: display_order must contain PMC/Committers arrays") + if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in ordered_ids): + raise RosterError(f"github-map.json: display_order.{role} contains an invalid ASF ID") + if len(ordered_ids) != len(set(ordered_ids)): + raise RosterError(f"github-map.json: display_order.{role} contains duplicate ASF IDs") + if roster_ids is not None and any(asf_id not in roster_ids for asf_id in ordered_ids): + raise RosterError(f"github-map.json: display_order.{role} contains an unknown ASF ID") + logins: set[str] = set() + user_ids: set[int] = set() + for asf_id, mapping in mappings.items(): + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"github-map.json: invalid ASF ID {asf_id!r}") + if roster_ids is not None and asf_id not in roster_ids: + raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}") + if not isinstance(mapping, dict): + raise RosterError(f"github-map.json: mapping for {asf_id!r} must be an object") + login, user_id = mapping.get("login"), mapping.get("user_id") + if not isinstance(login, str) or not login.strip() or login != login.strip(): + raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed login") + if not GITHUB_LOGIN_PATTERN.fullmatch(login) or "--" in login: + raise RosterError(f"github-map.json: invalid GitHub login {login!r}") + if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0: + raise RosterError(f"github-map.json: {asf_id!r} needs a positive numeric user_id") + if login.casefold() in logins: + raise RosterError(f"github-map.json: duplicate GitHub login {login!r}") + if user_id in user_ids: + raise RosterError(f"github-map.json: duplicate GitHub user_id {user_id}") + logins.add(login.casefold()) + user_ids.add(user_id) + return mappings + + +def _ordered_ids(ids: set[str], names: dict[str, str], mapping_data: dict, role: str) -> list[str]: + configured = mapping_data.get("display_order", {}).get(role, []) + if any(asf_id not in ids for asf_id in configured): + raise RosterError(f"github-map.json: display_order.{role} contains an ID outside its role") + configured_set = set(configured) + return list(configured) + sorted(ids - configured_set, key=lambda item: _sort_key(item, names)) + + +def _webp_dimensions(raw: bytes) -> tuple[int, int]: + if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + chunk = raw[12:16] + if chunk == b"VP8X": + return 1 + int.from_bytes(raw[24:27], "little"), 1 + int.from_bytes(raw[27:30], "little") + if chunk == b"VP8L": + bits = int.from_bytes(raw[21:25], "little") + return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF) + if chunk == b"VP8 ": + marker = raw.find(b"\x9d\x01\x2a", 20) + if marker < 0 or marker + 7 > len(raw): + raise RosterError("avatar has an invalid VP8 frame") + width, height = struct.unpack_from(" list[bytes]: + if len(raw) < 20 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + if int.from_bytes(raw[4:8], "little") != len(raw) - 8: + raise RosterError("avatar has an invalid RIFF length") + kinds: list[bytes] = [] + cursor = 12 + while cursor + 8 <= len(raw): + kind = raw[cursor : cursor + 4] + size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little") + cursor += 8 + size + (size % 2) + if cursor > len(raw): + raise RosterError("avatar has a truncated WebP chunk") + kinds.append(kind) + if cursor != len(raw): + raise RosterError("avatar has trailing WebP data") + return kinds + + +def _validate_webp(raw: bytes, expected_dimensions: tuple[int, int] | None = None) -> tuple[int, int]: + dimensions = _webp_dimensions(raw) + kinds = _webp_chunk_kinds(raw) + if not any(kind in {b"VP8 ", b"VP8L"} for kind in kinds): + raise RosterError("avatar has no decodable WebP image bitstream") + if any(kind in {b"EXIF", b"XMP ", b"ICCP"} for kind in kinds): + raise RosterError("avatar contains metadata") + if expected_dimensions and dimensions != expected_dimensions: + raise RosterError(f"avatar dimensions are {dimensions}, expected {expected_dimensions}") + decoder = shutil.which("dwebp") + if not decoder: + raise RosterError("validating mapped avatars requires dwebp") + with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-decode-") as work: + source = pathlib.Path(work) / "avatar.webp" + target = pathlib.Path(work) / "avatar.ppm" + source.write_bytes(raw) + try: + result = subprocess.run( + [decoder, str(source), "-o", str(target)], + text=True, + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RosterError(f"dwebp could not decode avatar: {exc}") from exc + if result.returncode or not target.is_file(): + raise RosterError(f"dwebp rejected avatar: {result.stderr.strip()}") + return dimensions + + +def _strip_webp_metadata(raw: bytes) -> bytes: + """Remove optional metadata chunks while preserving the image bitstream.""" + _webp_dimensions(raw) + chunks: list[bytes] = [] + cursor = 12 + while cursor + 8 <= len(raw): + kind = raw[cursor : cursor + 4] + size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little") + end = cursor + 8 + size + (size % 2) + if end > len(raw): + raise RosterError("avatar has a truncated WebP chunk") + chunk = bytearray(raw[cursor:end]) + if kind not in {b"EXIF", b"XMP ", b"ICCP"}: + if kind == b"VP8X": + chunk[8] &= ~0x2D + chunks.append(bytes(chunk)) + cursor = end + if cursor != len(raw): + raise RosterError("avatar has trailing WebP data") + payload = b"WEBP" + b"".join(chunks) + return b"RIFF" + len(payload).to_bytes(4, "little") + payload + + +def _avatar_bytes(user_id: int) -> bytes: + request = urllib.request.Request( + f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4", + headers={"Accept": "image/webp", "User-Agent": "apache-hugegraph-doc-community-roster/1"}, + ) + with _open_allowlisted(request, {"avatars.githubusercontent.com"}, "GitHub avatar") as response: + raw = _read_bounded_response( + response, + expected_hosts={"avatars.githubusercontent.com"}, + content_types={"image/png", "image/jpeg", "image/webp"}, + limit=AVATAR_LIMIT, + kind="GitHub avatar", + ) + try: + raw = _strip_webp_metadata(raw) + except RosterError: + converter = shutil.which("cwebp") + if not converter: + raise RosterError("mapped avatars require cwebp when GitHub does not return WebP") + with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-") as work: + source = pathlib.Path(work) / "source" + target = pathlib.Path(work) / "avatar.webp" + source.write_bytes(raw) + try: + result = subprocess.run( + [converter, "-quiet", "-resize", "128", "128", "-metadata", "none", str(source), "-o", str(target)], + text=True, + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {exc}") from exc + if result.returncode: + raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {result.stderr.strip()}") + raw = _strip_webp_metadata(target.read_bytes()) + _validate_webp(raw, expected_dimensions=(128, 128)) + return raw + + +def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict: + member = { + "asf_id": asf_id, + "name": name, + "initials": _initials(name), + "chair": chair, + "profile_url": f"https://people.apache.org/phonebook.html?uid={asf_id}", + } + if mapping: + member["github"] = {"login": mapping["login"], "user_id": mapping["user_id"]} + return member + + +def build_roster(committee_data: dict, projects_data: dict, people_data: dict, mapping_data: dict) -> dict: + projects = projects_data.get("projects") + committees = committee_data.get("committees") + if not isinstance(projects, dict) or not isinstance(committees, dict): + raise RosterError("ASF sources must contain projects and committees objects") + project = projects.get(PROJECT) + committee = committees.get(PROJECT) + if not isinstance(project, dict) or not isinstance(committee, dict): + raise RosterError("ASF sources do not contain the HugeGraph project") + owners, members = project.get("owners"), project.get("members") + chair_map, committee_roster = committee.get("chair"), committee.get("roster") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("LDAP project owners/members must be arrays") + if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in owners + members): + raise RosterError("LDAP project owners/members contain an invalid ASF ID") + for field, asf_ids in (("owners", owners), ("members", members)): + if len(asf_ids) != len(set(asf_ids)): + raise RosterError(f"LDAP project {field} contains duplicate ASF IDs") + if not isinstance(chair_map, dict) or len(chair_map) != 1: + raise RosterError("committee source must name exactly one Chair") + if not isinstance(committee_roster, dict): + raise RosterError("committee roster must be an object") + if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in [*chair_map, *committee_roster]): + raise RosterError("committee source contains an invalid ASF ID") + owner_ids, member_ids = set(owners), set(members) + chair = next(iter(chair_map)) + if not owner_ids <= member_ids: + raise RosterError("LDAP owners must be a subset of members") + if chair not in owner_ids: + raise RosterError("Chair must be an LDAP owner") + if owner_ids != set(committee_roster): + raise RosterError("committee roster and LDAP owners disagree") + mappings = _validate_mapping(mapping_data, member_ids) + names = {asf_id: _person_name(people_data, asf_id) for asf_id in member_ids} + names.update({asf_id: mapping["login"] for asf_id, mapping in mappings.items()}) + names.update({asf_id: public_name for asf_id, public_name in mapping_data.get("public_names", {}).items()}) + pmc_ids = [chair] + _ordered_ids(owner_ids - {chair}, names, mapping_data, "pmc") + committer_ids = _ordered_ids(member_ids - owner_ids, names, mapping_data, "committers") + return { + "schema_version": SCHEMA_VERSION, + "project": PROJECT, + "retrieved_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source": { + **SOURCES, + "chair": chair, + "owners": sorted(owner_ids), + "members": sorted(member_ids), + }, + "roles": { + "pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in pmc_ids], + "committers": [_member(i, names[i], False, mappings.get(i)) for i in committer_ids], + }, + } + + +def _install_avatars(candidate: dict, target: pathlib.Path) -> None: + target.mkdir(parents=True, exist_ok=True) + for role in ("pmc", "committers"): + for member in candidate["roles"][role]: + github = member.get("github") + if not github: + continue + raw = _avatar_bytes(github["user_id"]) + digest = hashlib.sha256(raw).hexdigest() + path = target / f"{digest}.webp" + if not path.exists(): + path.write_bytes(raw) + member["avatar"] = f"/img/community/avatars/{path.name}" + member["profile_url"] = f"https://github.com/{github['login']}" + + +def validate_bundle(warn_after_days: int) -> list[str]: + _validate_repo_paths() + roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH) + if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") != PROJECT: + raise RosterError("roster.json: unsupported schema_version or project") + roles, source = roster.get("roles"), roster.get("source") + if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}: + raise RosterError("roster.json: roles must contain only pmc and committers") + if not isinstance(source, dict): + raise RosterError("roster.json: source must be an object") + for key, url in SOURCES.items(): + if source.get(key) != url: + raise RosterError(f"roster.json: source.{key} is not authoritative") + owners, members, chair = source.get("owners"), source.get("members"), source.get("chair") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("roster.json: source owners/members must be arrays") + if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in owners + members): + raise RosterError("roster.json: source owners/members contain an invalid ASF ID") + if owners != sorted(set(owners)) or members != sorted(set(members)): + raise RosterError("roster.json: source owners/members must be sorted and unique") + if not set(owners) <= set(members) or chair not in owners: + raise RosterError("roster.json: invalid owners/members/Chair relationship") + if not isinstance(chair, str) or not ASF_ID_PATTERN.fullmatch(chair): + raise RosterError("roster.json: source Chair must be a valid ASF ID") + pmc, committers = roles["pmc"], roles["committers"] + if not isinstance(pmc, list) or not isinstance(committers, list) or not pmc: + raise RosterError("roster.json: invalid role arrays") + people = pmc + committers + for person in people: + if not isinstance(person, dict): + raise RosterError("roster.json: every role entry must be an object") + asf_id = person.get("asf_id") + if not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError("roster.json: role entry has an invalid ASF ID") + name = person.get("name") + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"roster.json: member name must be non-empty for {asf_id!r}") + if person.get("initials") != _initials(name): + raise RosterError(f"roster.json: member initials mismatch for {asf_id!r}") + if not isinstance(person.get("profile_url"), str): + raise RosterError(f"roster.json: member profile URL must be a string for {asf_id!r}") + if type(person.get("chair")) is not bool: + raise RosterError(f"roster.json: chair must be boolean for {asf_id!r}") + ids = [person["asf_id"] for person in people] + if len(ids) != len(set(ids)) or set(ids) != set(members): + raise RosterError("roster.json: members must match unique source ASF IDs") + if {p["asf_id"] for p in pmc} != set(owners): + raise RosterError("roster.json: PMC must equal owners") + if {p["asf_id"] for p in committers} != set(members) - set(owners): + raise RosterError("roster.json: Committers must equal members minus owners") + chairs = [person for person in people if person.get("chair") is True] + if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != chairs[0]: + raise RosterError("roster.json: unique Chair must be first in PMC") + mappings = _validate_mapping(mapping, set(ids)) + names = {person["asf_id"]: person["name"] for person in people} + expected_pmc = [chair] + _ordered_ids(set(owners) - {chair}, names, mapping, "pmc") + expected_committers = _ordered_ids(set(members) - set(owners), names, mapping, "committers") + if [person["asf_id"] for person in pmc] != expected_pmc: + raise RosterError("roster.json: PMC order does not match display_order or sorted by public name") + if [person["asf_id"] for person in committers] != expected_committers: + raise RosterError("roster.json: Committers order does not match display_order or sorted by public name") + public_names = mapping.get("public_names", {}) + for person in people: + if person["asf_id"] in public_names and person["name"] != public_names[person["asf_id"]]: + raise RosterError(f"roster.json: public name drift for {person['asf_id']!r}") + expected, avatar = mappings.get(person["asf_id"]), person.get("avatar") + if expected != person.get("github"): + raise RosterError(f"roster.json: GitHub mapping drift for {person['asf_id']!r}") + if expected: + match = AVATAR_PATH_PATTERN.fullmatch(avatar) if isinstance(avatar, str) else None + if not match: + raise RosterError(f"roster.json: mapped member {person['asf_id']!r} needs a local avatar") + filename = f"{match.group(1)}.webp" + path = AVATAR_DIR / filename + if path.is_symlink(): + raise RosterError(f"roster.json: avatar must not be a symlink {avatar}") + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != match.group(1): + raise RosterError(f"roster.json: invalid avatar {avatar}") + _validate_webp(raw, expected_dimensions=(128, 128)) + if person["profile_url"] != f"https://github.com/{expected['login']}": + raise RosterError(f"roster.json: mapped profile URL mismatch") + elif avatar: + raise RosterError(f"roster.json: unmapped member has an avatar") + elif person["profile_url"] != f"https://people.apache.org/phonebook.html?uid={person['asf_id']}": + raise RosterError(f"roster.json: unmapped profile URL mismatch for {person['asf_id']!r}") + retrieved_at = roster.get("retrieved_at") + if not isinstance(retrieved_at, str): + raise RosterError("roster.json: retrieved_at must be an ISO-8601 UTC string") + try: + retrieved = dt.datetime.fromisoformat(retrieved_at.replace("Z", "+00:00")) + except (KeyError, TypeError, ValueError) as exc: + raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") from exc + now = dt.datetime.now(dt.timezone.utc) + if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5): + raise RosterError("roster.json: retrieved_at is in the future or lacks a timezone") + age = now - retrieved + return [f"community roster is {age.days} days old (threshold: {warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else [] + + +class _CommunityLinkParser(html.parser.HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.role_stack: list[str | None] = [] + self.section_order: list[str] = [] + self.links = {"pmc": [], "committers": []} + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if tag == "section": + role = attributes.get("data-community-role") + if role in self.links: + self.section_order.append(role) + self.role_stack.append(role if role in self.links else None) + elif tag == "a" and self.role_stack and self.role_stack[-1]: + href = attributes.get("href") + if href: + self.links[self.role_stack[-1]].append(href) + + def handle_endtag(self, tag: str) -> None: + if tag == "section" and self.role_stack: + self.role_stack.pop() + + +def _rendered_role_links(rendered: str, html_output: bool) -> dict[str, list[str]]: + if html_output: + parser = _CommunityLinkParser() + parser.feed(rendered) + if parser.section_order != ["pmc", "committers"]: + raise RosterError("Community role section order drift") + return parser.links + starts = {} + for role, heading in (("pmc", "PMC"), ("committers", "Committers")): + match = re.search(rf"(?m)^### {heading}\s*$", rendered) + if not match: + return {"pmc": [], "committers": []} + starts[role] = match.end() + if starts["pmc"] >= starts["committers"]: + return {"pmc": [], "committers": []} + tail = rendered[starts["committers"] :] + next_heading = re.search(r"(?m)^##\s", tail) + segments = { + "pmc": rendered[starts["pmc"] : starts["committers"]], + "committers": tail[: next_heading.start()] if next_heading else tail, + } + return { + role: re.findall(r"(?m)^-\s+\[[^\]]+\]\(([^)\s]+)\)", segment) + for role, segment in segments.items() + } + + +def validate_rendered_outputs(destination: pathlib.Path) -> None: + expected = { + "community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "community/index.md": ("## Project members", "### PMC", "### Committers"), + "cn/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"), + } + roster_roles = _read_json(ROSTER_PATH)["roles"] + for relative, markers in expected.items(): + path = destination / relative + if not path.is_file(): + raise RosterError(f"rendered output is missing {relative}") + rendered = path.read_text(encoding="utf-8") + if relative.endswith(".html"): + has_markers = all( + re.search(rf'data-community-role=(?:"{role}"|{role})(?:\s|>)', rendered) + for role in ("pmc", "committers") + ) + else: + has_markers = all(marker in rendered for marker in markers) + if not has_markers: + raise RosterError(f"rendered output {relative} is missing Community markers") + rendered_links = _rendered_role_links(rendered, relative.endswith(".html")) + for role, entries in roster_roles.items(): + # Only reviewed GitHub mappings are actionable links. Unmapped + # ASF members remain visible as static identity cards. + expected_links = [person["profile_url"] for person in entries if person.get("github")] + if rendered_links[role] != expected_links: + raise RosterError(f"rendered output {relative} has {role} link parity drift") + + +def _atomic_write(path: pathlib.Path, raw: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = pathlib.Path(temporary) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + +def _unlink(path: pathlib.Path) -> None: + path.unlink() + + +def _copy_candidate(raw: bytes, destination: pathlib.Path) -> None: + # An interrupted refresh may leave the temporary path behind. Remove only + # that exact path (including a symlink) before recreating it exclusively. + if destination.exists() or destination.is_symlink(): + destination.unlink() + with destination.open("xb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + + +def _validate_avatar_blob(name: str, raw: bytes) -> None: + match = re.fullmatch(r"([0-9a-f]{64})\.webp", name) + if not match or hashlib.sha256(raw).hexdigest() != match.group(1): + raise RosterError(f"candidate avatar name/hash mismatch: {name}") + _validate_webp(raw, expected_dimensions=(128, 128)) + + +def _assert_repo_path(path: pathlib.Path, label: str) -> None: + root = ROOT.absolute() + candidate = path.absolute() + try: + relative = candidate.relative_to(root) + except ValueError as exc: + raise RosterError(f"{label} must stay inside the repository") from exc + current = root + if current.is_symlink(): + raise RosterError("repository root must not be a symlink") + for part in relative.parts: + current /= part + if current.is_symlink(): + raise RosterError(f"{label} must not contain symlink path components") + try: + candidate.resolve(strict=False).relative_to(root.resolve(strict=True)) + except (OSError, ValueError) as exc: + raise RosterError(f"{label} resolves outside the repository") from exc + + +def _validate_repo_paths() -> None: + _assert_repo_path(DATA_DIR, "community data directory") + _assert_repo_path(ROSTER_PATH, "community roster") + _assert_repo_path(MAP_PATH, "GitHub mapping") + _assert_repo_path(AVATAR_DIR, "community avatar directory") + + +def _commit_bundle(candidate: dict, candidate_avatars: dict[str, bytes]) -> None: + """Install verified immutable assets, then atomically publish the roster.""" + _validate_repo_paths() + AVATAR_DIR.mkdir(parents=True, exist_ok=True) + referenced = { + pathlib.PurePosixPath(person["avatar"]).name + for role in candidate["roles"].values() + for person in role + if person.get("avatar") + } + existing = {path.name: path for path in AVATAR_DIR.glob("*.webp")} + for name, avatar in sorted(candidate_avatars.items()): + _validate_avatar_blob(name, avatar) + destination = AVATAR_DIR / name + if destination.exists() or destination.is_symlink(): + try: + if destination.is_symlink(): + raise RosterError(f"candidate destination is a symlink: {destination}") + _validate_avatar_blob(name, destination.read_bytes()) + continue + except RosterError: + pass + staged = AVATAR_DIR / f".{name}.candidate" + try: + _copy_candidate(avatar, staged) + os.replace(staged, destination) + finally: + if staged.exists(): + _unlink(staged) + raw = (json.dumps(candidate, indent=2, ensure_ascii=False) + "\n").encode() + # This atomic replace is the commit point. Failures before it leave the + # last-good roster selected; installed content-addressed assets are safe + # unreferenced candidates. + _atomic_write(ROSTER_PATH, raw) + for name in sorted(set(existing) - referenced): + try: + _unlink(AVATAR_DIR / name) + except OSError as exc: + print( + f"::warning file=static/img/community/avatars/{name}::" + f"could not remove unreferenced avatar: {exc}", + file=sys.stderr, + ) + + +def refresh() -> None: + _validate_repo_paths() + DATA_DIR.mkdir(parents=True, exist_ok=True) + source_data = {key: _fetch_json(url) for key, url in SOURCES.items()} + candidate = build_roster(source_data["committee"], source_data["projects"], source_data["people"], _read_json(MAP_PATH)) + work = pathlib.Path(tempfile.mkdtemp(prefix=".community-refresh-", dir=DATA_DIR)) + try: + candidate_avatars = work / "avatars" + _install_avatars(candidate, candidate_avatars) + avatar_bytes = {path.name: path.read_bytes() for path in candidate_avatars.glob("*.webp")} + finally: + # Candidate cleanup is deliberately completed before the checked-in + # bundle changes, so cleanup failure cannot publish a new roster. + shutil.rmtree(work) + _commit_bundle(candidate, avatar_bytes) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("refresh") + validate = commands.add_parser("validate") + validate.add_argument("--warn-after-days", type=int, default=90) + validate.add_argument("--artifact", type=pathlib.Path, help="validate a prebuilt Hugo artifact") + args = parser.parse_args() + try: + if args.command == "refresh": + refresh() + else: + if args.warn_after_days < 0: + raise RosterError("--warn-after-days must be non-negative") + for warning in validate_bundle(args.warn_after_days): + print(f"::warning file=data/community/roster.json::{warning}") + if args.artifact: + validate_rendered_outputs(args.artifact.resolve()) + except (OSError, RosterError, urllib.error.URLError) as exc: + print(f"community roster: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fixtures/community_search_queries.json b/scripts/fixtures/community_search_queries.json new file mode 100644 index 0000000000..a65cfdcb3c --- /dev/null +++ b/scripts/fixtures/community_search_queries.json @@ -0,0 +1,26 @@ +[ + {"locale": "en", "query": "HugeGraph overview", "expected_ref": "/docs/introduction/"}, + {"locale": "en", "query": "server quickstart", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-server/"}, + {"locale": "en", "query": "distributed storage", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-hstore/"}, + {"locale": "en", "query": "placement driver", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-pd/"}, + {"locale": "en", "query": "graph computing", "expected_ref": "/docs/quickstart/computing/hugegraph-computer/"}, + {"locale": "en", "query": "bulk import", "expected_ref": "/docs/quickstart/toolchain/hugegraph-loader/"}, + {"locale": "en", "query": "graph visualization", "expected_ref": "/docs/quickstart/toolchain/hugegraph-hubble/"}, + {"locale": "en", "query": "Java client", "expected_ref": "/docs/clients/"}, + {"locale": "en", "query": "HugeGraph REST API", "expected_ref": "/docs/clients/restful-api/"}, + {"locale": "en", "query": "server config", "expected_ref": "/docs/config/config-guide/"}, + {"locale": "en", "query": "StandardAuthenticator", "expected_ref": "/docs/config/config-authentication/"}, + {"locale": "en", "query": "release artifacts", "expected_ref": "/docs/download/download/"}, + {"locale": "cn", "query": "HugeGraph 介绍", "expected_ref": "/cn/docs/introduction/"}, + {"locale": "cn", "query": "Server 快速开始", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-server/"}, + {"locale": "cn", "query": "分布式存储", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-hstore/"}, + {"locale": "cn", "query": "元数据管理", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-pd/"}, + {"locale": "cn", "query": "图计算", "expected_ref": "/cn/docs/quickstart/computing/hugegraph-computer/"}, + {"locale": "cn", "query": "批量导入", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-loader/"}, + {"locale": "cn", "query": "图可视化", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-hubble/"}, + {"locale": "cn", "query": "Java 客户端", "expected_ref": "/cn/docs/clients/"}, + {"locale": "cn", "query": "HugeGraph REST API", "expected_ref": "/cn/docs/clients/restful-api/"}, + {"locale": "cn", "query": "Server 配置", "expected_ref": "/cn/docs/config/config-guide/"}, + {"locale": "cn", "query": "权限配置", "expected_ref": "/cn/docs/config/config-authentication/"}, + {"locale": "cn", "query": "发布包", "expected_ref": "/cn/docs/download/download/"} +] diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py new file mode 100644 index 0000000000..d916c444b5 --- /dev/null +++ b/scripts/test_community_roster.py @@ -0,0 +1,820 @@ +import hashlib +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / "scripts" / "community_roster.py") +roster = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(roster) + + +def strip_github_mappings(candidate: dict) -> dict: + for person in candidate["roles"]["pmc"] + candidate["roles"]["committers"]: + person.pop("github", None) + person.pop("avatar", None) + person["profile_url"] = f"https://people.apache.org/phonebook.html?uid={person['asf_id']}" + return candidate + + +class FakeResponse: + def __init__(self, raw, *, url, content_type, status=200): + self.raw = raw + self.url = url + self.headers = {"Content-Type": content_type} + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def geturl(self): + return self.url + + def read(self, limit=-1): + return self.raw if limit < 0 else self.raw[:limit] + + +class CommunityRosterTests(unittest.TestCase): + def fixture(self): + return ( + {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair Person"}}, "roster": {"chair": {}, "zeta": {}}}}}, + {"projects": {"hugegraph": {"owners": ["zeta", "chair"], "members": ["other", "zeta", "chair"]}}}, + {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": "Alpha Owner"}, "other": {"name": "Beta Committer"}}}, + {"schema_version": 1, "mappings": {}}, + ) + + def test_build_roster_derives_roles_and_order(self): + candidate = roster.build_roster(*self.fixture()) + self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in candidate["roles"]["pmc"]]) + self.assertEqual(["other"], [p["asf_id"] for p in candidate["roles"]["committers"]]) + self.assertTrue(candidate["roles"]["pmc"][0]["chair"]) + + def test_github_login_is_default_display_name_with_explicit_name_override(self): + committee, projects, people, _ = self.fixture() + mapping = { + "schema_version": 1, + "mappings": {"zeta": {"login": "willem-user", "user_id": 1}}, + } + candidate = roster.build_roster(committee, projects, people, mapping) + zeta = next(person for person in candidate["roles"]["pmc"] if person["asf_id"] == "zeta") + self.assertEqual("willem-user", zeta["name"]) + mapping["public_names"] = {"zeta": "Willem Jiang"} + candidate = roster.build_roster(committee, projects, people, mapping) + zeta = next(person for person in candidate["roles"]["pmc"] if person["asf_id"] == "zeta") + self.assertEqual("Willem Jiang", zeta["name"]) + + def test_display_order_can_override_public_name_sorting(self): + committee, projects, people, _ = self.fixture() + projects["projects"]["hugegraph"]["owners"] = ["zeta", "chair", "alpha"] + projects["projects"]["hugegraph"]["members"] = ["other", "zeta", "chair", "alpha"] + committee["committees"]["hugegraph"]["roster"]["alpha"] = {} + people["people"]["alpha"] = {"name": "Carp84"} + mapping = {"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["zeta", "alpha"]}} + candidate = roster.build_roster(committee, projects, people, mapping) + self.assertEqual(["chair", "zeta", "alpha"], [p["asf_id"] for p in candidate["roles"]["pmc"]]) + + def test_same_names_use_asf_id_tiebreaker_across_hash_seeds(self): + program = f""" +import importlib.util, json +spec = importlib.util.spec_from_file_location("community_roster", {str(ROOT / "scripts/community_roster.py")!r}) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +committee = {{"committees": {{"hugegraph": {{"chair": {{"chair": {{}}}}, "roster": {{"chair": {{}}, "zeta": {{}}, "alpha": {{}}}}}}}}}} +projects = {{"projects": {{"hugegraph": {{"owners": ["zeta", "chair", "alpha"], "members": ["zeta", "chair", "alpha"]}}}}}} +people = {{"people": {{"chair": {{"name": "Chair"}}, "zeta": {{"name": "Same Name"}}, "alpha": {{"name": "Same Name"}}}}}} +result = module.build_roster(committee, projects, people, {{"schema_version": 1, "mappings": {{}}}}) +print(json.dumps([person["asf_id"] for person in result["roles"]["pmc"]])) +""" + outputs = [] + for seed in ("1", "777"): + environment = {**os.environ, "PYTHONHASHSEED": seed} + outputs.append(subprocess.check_output([sys.executable, "-c", program], env=environment, text=True)) + self.assertEqual(outputs[0], outputs[1]) + self.assertEqual(["chair", "alpha", "zeta"], json.loads(outputs[0])) + + def test_build_roster_rejects_committee_ldap_drift(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"].pop("zeta") + with self.assertRaisesRegex(roster.RosterError, "disagree"): + roster.build_roster(committee, projects, people, mapping) + + def test_build_roster_rejects_duplicate_ldap_ids(self): + for field in ("owners", "members"): + with self.subTest(field=field): + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"][field].append( + projects["projects"]["hugegraph"][field][0] + ) + with self.assertRaisesRegex( + roster.RosterError, + rf"LDAP project {field} contains duplicate ASF IDs", + ): + roster.build_roster(committee, projects, people, mapping) + + def test_mapping_requires_unique_numeric_ids(self): + mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", "user_id": 1}, "two": {"login": "other", "user_id": 1}}} + with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub user_id"): + roster._validate_mapping(mapping, {"one", "two"}) + + def test_mapping_rejects_invalid_identity_characters(self): + with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"): + roster._validate_mapping( + {"schema_version": 1, "mappings": {"Bad ID": {"login": "valid", "user_id": 1}}}, + {"Bad ID"}, + ) + with self.assertRaisesRegex(roster.RosterError, "invalid GitHub login"): + roster._validate_mapping( + {"schema_version": 1, "mappings": {"valid": {"login": "bad/login", "user_id": 1}}}, + {"valid"}, + ) + + def test_avatar_metadata_is_stripped(self): + vp8x = b"VP8X" + (10).to_bytes(4, "little") + bytes([0x2D]) + b"\0" * 9 + exif = b"EXIF" + (4).to_bytes(4, "little") + b"meta" + iccp = b"ICCP" + (4).to_bytes(4, "little") + b"icc!" + payload = b"WEBP" + vp8x + exif + iccp + raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload + stripped = roster._strip_webp_metadata(raw) + self.assertNotIn(b"EXIF", stripped) + self.assertNotIn(b"ICCP", stripped) + self.assertEqual(0, stripped[20] & 0x2D) + + def test_truncated_vp8x_without_image_bitstream_is_rejected(self): + vp8x = b"VP8X" + (10).to_bytes(4, "little") + b"\0" * 10 + payload = b"WEBP" + vp8x + raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload + with self.assertRaisesRegex(roster.RosterError, "no decodable"): + roster._validate_webp(raw) + + def test_network_response_contracts_are_bounded_and_allowlisted(self): + with self.assertRaisesRegex(roster.RosterError, "not allowlisted"): + roster._read_bounded_response( + FakeResponse(b"{}", url="https://evil.example/data", content_type="application/json"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + with self.assertRaisesRegex(roster.RosterError, "Content-Type"): + roster._read_bounded_response( + FakeResponse(b"{}", url="https://whimsy.apache.org/data", content_type="text/html"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + with self.assertRaisesRegex(roster.RosterError, "exceeds"): + roster._read_bounded_response( + FakeResponse(b"x" * 11, url="https://whimsy.apache.org/data", content_type="application/json"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + + def test_redirect_is_rejected_before_following_disallowed_host(self): + handler = roster._AllowlistedRedirectHandler({"whimsy.apache.org"}, "JSON source") + with self.assertRaisesRegex(roster.RosterError, "not allowlisted"): + handler.redirect_request( + mock.Mock(), + None, + 302, + "Found", + {}, + "http://127.0.0.1/private", + ) + + def test_malformed_json_and_encoder_timeout_are_roster_errors(self): + response = FakeResponse( + b"{bad", + url="https://whimsy.apache.org/public/committee-info.json", + content_type="application/json", + ) + with mock.patch.object(roster, "_open_allowlisted", return_value=response): + with self.assertRaisesRegex(roster.RosterError, "malformed JSON"): + roster._fetch_json(roster.SOURCES["committee"]) + avatar = FakeResponse( + b"not-an-image", + url="https://avatars.githubusercontent.com/u/1?s=128&v=4", + content_type="image/png", + ) + with mock.patch.object(roster, "_open_allowlisted", return_value=avatar), \ + mock.patch.object(roster.shutil, "which", return_value="/fake/cwebp"), \ + mock.patch.object(roster.subprocess, "run", side_effect=subprocess.TimeoutExpired("cwebp", 20)): + with self.assertRaisesRegex(roster.RosterError, "cwebp failed"): + roster._avatar_bytes(1) + + def test_nested_source_schema_errors_are_roster_errors(self): + committee, projects, people, mapping = self.fixture() + projects["projects"] = [] + with self.assertRaisesRegex(roster.RosterError, "projects and committees objects"): + roster.build_roster(committee, projects, people, mapping) + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"]["owners"] = [[]] + with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"): + roster.build_roster(committee, projects, people, mapping) + with tempfile.TemporaryDirectory(prefix="community-json-root-") as directory: + path = pathlib.Path(directory) / "array.json" + path.write_text("[]") + with self.assertRaisesRegex(roster.RosterError, "JSON root must be an object"): + roster._read_json(path) + + def test_checked_in_bundle_validates(self): + self.assertEqual([], roster.validate_bundle(90)) + + def test_unmapped_profile_must_be_exact_phonebook_url(self): + with tempfile.TemporaryDirectory(prefix="community-profile-test-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(roster.ROSTER_PATH.read_text()) + strip_github_mappings(candidate) + candidate["roles"]["committers"][0]["profile_url"] = "https://example.invalid/profile" + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps({"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}})) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "unmapped profile URL mismatch"): + roster.validate_bundle(90) + + def test_chair_values_must_be_strict_booleans(self): + with tempfile.TemporaryDirectory(prefix="community-chair-test-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(roster.ROSTER_PATH.read_text()) + candidate["roles"]["committers"][0]["chair"] = 0 + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(roster.MAP_PATH.read_text()) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "chair must be boolean"): + roster.validate_bundle(90) + + def test_avatar_path_rejects_extra_segments_and_symlinks(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + strip_github_mappings(base) + asf_id = base["roles"]["committers"][0]["asf_id"] + mapping = {"schema_version": 1, "mappings": {asf_id: {"login": "valid-user", "user_id": 1}}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}} + for avatar in ( + "/img/community/avatars/extra/" + "a" * 64 + ".webp", + "/img/community/avatars/../" + "a" * 64 + ".webp", + ): + with self.subTest(avatar=avatar), tempfile.TemporaryDirectory(prefix="community-avatar-path-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id) + member.update(github=mapping["mappings"][asf_id], avatar=avatar, profile_url="https://github.com/valid-user") + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "needs a local avatar"): + roster.validate_bundle(90) + with tempfile.TemporaryDirectory(prefix="community-avatar-link-") as directory: + root = pathlib.Path(directory) + avatar_dir = root / "avatars" + avatar_dir.mkdir() + raw = b"target" + digest = hashlib.sha256(raw).hexdigest() + target = root / "target.webp" + target.write_bytes(raw) + (avatar_dir / f"{digest}.webp").symlink_to(target) + candidate = json.loads(json.dumps(base)) + member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id) + member.update( + github=mapping["mappings"][asf_id], + avatar=f"/img/community/avatars/{digest}.webp", + profile_url="https://github.com/valid-user", + ) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir): + with self.assertRaisesRegex(roster.RosterError, "must not be a symlink"): + roster.validate_bundle(90) + + def test_avatar_directory_parent_symlink_is_rejected(self): + with tempfile.TemporaryDirectory(prefix="community-avatar-parent-") as directory: + root = pathlib.Path(directory) + outside = root / "outside" + outside.mkdir() + avatar_link = root / "static" / "img" / "community" / "avatars" + avatar_link.parent.mkdir(parents=True) + avatar_link.symlink_to(outside, target_is_directory=True) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root / "data" / "community"), \ + mock.patch.object(roster, "ROSTER_PATH", root / "data" / "community" / "roster.json"), \ + mock.patch.object(roster, "MAP_PATH", root / "data" / "community" / "github-map.json"), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_link): + with self.assertRaisesRegex(roster.RosterError, "symlink path components"): + roster._validate_repo_paths() + + def test_member_name_and_initials_must_be_non_empty_and_derived(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + mapping = {"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}} + for field, value, message in ( + ("name", "", "name must be non-empty"), + ("initials", "", "initials mismatch"), + ): + with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-identity-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + candidate["roles"]["committers"][0][field] = value + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, message): + roster.validate_bundle(90) + + def test_local_roster_schema_errors_are_roster_errors(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + mapping = {"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}} + mutations = ( + ("asf_id", [], "invalid ASF ID"), + ("name", 123, "name must be non-empty"), + ("retrieved_at", None, "ISO-8601 UTC string"), + ) + for field, value, message in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-schema-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + strip_github_mappings(candidate) + if field == "retrieved_at": + candidate[field] = value + else: + candidate["roles"]["committers"][0][field] = value + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, message): + roster.validate_bundle(90) + + def test_refresh_validates_paths_before_creating_data_directory(self): + with tempfile.TemporaryDirectory(prefix="community-refresh-path-") as directory: + root = pathlib.Path(directory) + outside = root / "outside" + outside.mkdir() + (root / "data").symlink_to(outside, target_is_directory=True) + data_dir = root / "data" / "community" + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", data_dir), \ + mock.patch.object(roster, "ROSTER_PATH", data_dir / "roster.json"), \ + mock.patch.object(roster, "MAP_PATH", data_dir / "github-map.json"), \ + mock.patch.object(roster, "AVATAR_DIR", root / "static" / "img" / "community" / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "symlink path components"): + roster.refresh() + self.assertFalse((outside / "community").exists()) + + def test_validator_rejects_same_name_out_of_asf_id_order(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"]["alpha"] = {} + projects["projects"]["hugegraph"]["owners"].append("alpha") + projects["projects"]["hugegraph"]["members"].append("alpha") + people["people"]["zeta"]["name"] = "Same Name" + people["people"]["alpha"] = {"name": "Same Name"} + candidate = roster.build_roster(committee, projects, people, mapping) + candidate["roles"]["pmc"][1:] = reversed(candidate["roles"]["pmc"][1:]) + with tempfile.TemporaryDirectory(prefix="community-order-test-") as directory: + root = pathlib.Path(directory) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "sorted by public name"): + roster.validate_bundle(90) + + def test_fetch_failure_preserves_last_good(self): + original, old_fetch = roster.ROSTER_PATH.read_bytes(), roster._fetch_json + try: + roster._fetch_json = lambda _url: (_ for _ in ()).throw(OSError("network down")) + with self.assertRaises(OSError): + roster.refresh() + finally: + roster._fetch_json = old_fetch + self.assertEqual(original, roster.ROSTER_PATH.read_bytes()) + + def test_refresh_duplicate_ldap_ids_preserves_last_good(self): + for field in ("owners", "members"): + with self.subTest(field=field), tempfile.TemporaryDirectory( + prefix="community-duplicate-test-" + ) as directory: + root = pathlib.Path(directory) + data_dir = root / "data" + roster_path, map_path = data_dir / "roster.json", data_dir / "github-map.json" + data_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"][field].append( + projects["projects"]["hugegraph"][field][0] + ) + sources = { + roster.SOURCES["committee"]: committee, + roster.SOURCES["projects"]: projects, + roster.SOURCES["people"]: people, + } + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", data_dir), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"), \ + mock.patch.object(roster, "_fetch_json", side_effect=sources.__getitem__), \ + mock.patch.object(roster, "_commit_bundle") as commit: + with self.assertRaisesRegex( + roster.RosterError, + rf"LDAP project {field} contains duplicate ASF IDs", + ): + roster.refresh() + commit.assert_not_called() + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_copy_failure_preserves_last_good_bundle(self): + with tempfile.TemporaryDirectory(prefix="community-copy-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + (avatar_dir / "old.webp").write_bytes(b"old") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_copy_candidate", side_effect=OSError("copy failed")): + with self.assertRaisesRegex(OSError, "copy failed"): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes()) + + def test_candidate_cleanup_failure_does_not_publish_roster(self): + with tempfile.TemporaryDirectory(prefix="community-cleanup-test-") as directory: + root = pathlib.Path(directory) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_bytes(b"last-good\n") + map_path.write_text('{"schema_version": 1, "mappings": {}}') + candidate = {"roles": {"pmc": [], "committers": []}} + with mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_fetch_json", return_value={}), \ + mock.patch.object(roster, "build_roster", return_value=candidate), \ + mock.patch.object(roster, "_install_avatars"), \ + mock.patch.object(roster.shutil, "rmtree", side_effect=OSError("cleanup failed")): + with self.assertRaisesRegex(OSError, "cleanup failed"): + roster.refresh() + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_atomic_roster_write_failure_keeps_last_good_selected(self): + with tempfile.TemporaryDirectory(prefix="community-write-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_atomic_write", side_effect=OSError("write failed")): + with self.assertRaisesRegex(OSError, "write failed"): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_orphan_unlink_failure_is_a_successful_commit_warning(self): + with tempfile.TemporaryDirectory(prefix="community-unlink-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + (avatar_dir / "old.webp").write_bytes(b"old") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + real_unlink, failed = roster._unlink, False + + def fail_once(path): + nonlocal failed + if path.name == "old.webp" and not failed: + failed = True + raise OSError("unlink failed") + real_unlink(path) + + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_unlink", side_effect=fail_once): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertNotEqual(b"last-good\n", roster_path.read_bytes()) + self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes()) + self.assertEqual(b"new", (avatar_dir / "new.webp").read_bytes()) + + def test_corrupt_existing_candidate_destination_is_replaced(self): + with tempfile.TemporaryDirectory(prefix="community-replace-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + raw = b"new" + name = f"{__import__('hashlib').sha256(raw).hexdigest()}.webp" + destination = avatar_dir / name + destination.write_bytes(b"corrupt") + candidate = {"roles": {"pmc": [{"avatar": f"/img/community/avatars/{name}"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_webp"): + roster._commit_bundle(candidate, {name: raw}) + self.assertEqual(raw, destination.read_bytes()) + + +class CommunityContentContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._site = tempfile.TemporaryDirectory(prefix="community-content-site-") + hugo_version = subprocess.check_output(["hugo", "version"], text=True) + if not hugo_version.startswith("hugo v0.165.0") or "+extended" not in hugo_version: + raise RuntimeError( + f"Community render contracts require Hugo v0.165.0 Extended: {hugo_version.strip()}" + ) + environment = {**os.environ, "GOPROXY": "off"} + subprocess.run( + ["hugo", "--quiet", "--destination", cls._site.name], + cwd=ROOT, + env=environment, + text=True, + capture_output=True, + check=True, + ) + cls.site = pathlib.Path(cls._site.name) + + @classmethod + def tearDownClass(cls): + cls._site.cleanup() + + def test_search_metadata_covers_fixed_bilingual_entries(self): + entries = [ + "docs/introduction/_index.md", + "docs/quickstart/hugegraph/hugegraph-server.md", + "docs/quickstart/hugegraph/hugegraph-hstore.md", + "docs/quickstart/hugegraph/hugegraph-pd.md", + "docs/quickstart/computing/hugegraph-computer.md", + "docs/quickstart/toolchain/hugegraph-loader.md", + "docs/quickstart/toolchain/hugegraph-hubble.md", + "docs/clients/_index.md", + "docs/clients/restful-api/_index.md", + "docs/config/config-guide.md", + "docs/config/config-authentication.md", + "docs/download/download.md", + ] + for language in ("en", "cn"): + for relative in entries: + text = (ROOT / "content" / language / relative).read_text(encoding="utf-8") + self.assertIn("search_keywords:", text, f"{language}/{relative}") + self.assertIn("search_boost:", text, f"{language}/{relative}") + + def test_docs_roots_respect_core_platform_llmsfull_ownership(self): + versions = { + entry["id"] + for entry in json.loads((ROOT / "versions.json").read_text(encoding="utf-8"))["versions"] + } + core_platform_integrated = {"1.3", "1.0"} <= versions + for language in ("en", "cn"): + text = (ROOT / "content" / language / "docs/_index.md").read_text(encoding="utf-8") + frontmatter = text.split("---", 2)[1] + if core_platform_integrated: + self.assertIn("outputs: [HTML, RSS, print, markdown, LLMSFULL]", frontmatter) + else: + self.assertNotIn("LLMSFULL", frontmatter) + + def test_component_pilots_are_bilingual_and_scoped(self): + for language in ("en", "cn"): + server = (ROOT / "content" / language / "docs/quickstart/hugegraph/hugegraph-server.md").read_text() + config = (ROOT / "content" / language / "docs/config/config-guide.md").read_text() + vertex = (ROOT / "content" / language / "docs/clients/restful-api/vertex.md").read_text() + self.assertIn("{.steps}", server) + self.assertIn('filename="conf/gremlin-server.yaml"', config) + self.assertIn(".full-width", vertex) + self.assertIn("{#vertex-id-strategy", vertex) + + def test_component_pilots_render_in_html_print_and_markdown(self): + for prefix in ("", "cn/"): + outputs = { + "server_html": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.html", + "server_print": self.site / prefix / "_print/docs/quickstart/hugegraph/index.html", + "server_md": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.md", + "config_html": self.site / prefix / "docs/config/config-guide/index.html", + "config_print": self.site / prefix / "_print/docs/config/index.html", + "config_md": self.site / prefix / "docs/config/config-guide/index.md", + "vertex_html": self.site / prefix / "docs/clients/restful-api/vertex/index.html", + "vertex_print": self.site / prefix / "_print/docs/clients/restful-api/index.html", + "vertex_md": self.site / prefix / "docs/clients/restful-api/vertex/index.md", + } + rendered = {key: path.read_text(encoding="utf-8") for key, path in outputs.items()} + self.assertIn('class="steps"', rendered["server_html"]) + self.assertIn('class="steps"', rendered["server_print"]) + self.assertIn("{.steps}", rendered["server_md"]) + for key in ("config_html", "config_print", "config_md"): + self.assertIn("conf/gremlin-server.yaml", rendered[key]) + self.assertIn('id="vertex-id-strategy"', rendered["vertex_html"]) + self.assertIn('id="vertex-id-strategy"', rendered["vertex_print"]) + self.assertIn("{#vertex-id-strategy .full-width", rendered["vertex_md"]) + + def test_community_markdown_follows_section_order_and_about_is_unchanged(self): + expected = { + "community/index.md": ( + "## Join the Apache HugeGraph community", + "## Project members", + "## Get involved", + "## Learn how the project works", + ), + "cn/community/index.md": ( + "## 加入 Apache HugeGraph 社区", + "## 项目成员", + "## 参与社区", + "## 了解项目运作方式", + ), + } + for relative, markers in expected.items(): + rendered = (self.site / relative).read_text(encoding="utf-8") + expected_title = "# 社区" if relative.startswith("cn/") else "# Community" + self.assertTrue(rendered.startswith(expected_title + "\n")) + self.assertNotIn("td-page-meta__footer", rendered) + positions = [rendered.index(marker) for marker in markers] + self.assertEqual(positions, sorted(positions)) + member_heading = "项目成员" if relative.startswith("cn/") else "Project members" + self.assertRegex(rendered, rf"(?m)^## {member_heading}$") + about = { + "about/index.md": ( + "## One ecosystem for graph data and graph intelligence", + "HugeGraph is an Apache top-level project", + ), + "cn/about/index.md": ( + "## 连接图数据与图智能的一体化生态", + "HugeGraph 是 Apache 顶级项目", + ), + } + for relative, markers in about.items(): + rendered = (self.site / relative).read_text(encoding="utf-8") + self.assertNotIn("Project members", rendered) + for marker in markers: + self.assertIn(marker, rendered) + + def test_explicit_artifact_validator_accepts_prebuilt_site(self): + roster.validate_rendered_outputs(self.site) + result = subprocess.run( + [ + sys.executable, + "scripts/community_roster.py", + "validate", + "--warn-after-days", + "90", + "--artifact", + str(self.site), + ], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_artifact_validator_rejects_swapped_role_sections(self): + outputs = ( + "community/index.html", + "_print/community/index.html", + "community/index.md", + "cn/community/index.html", + "cn/_print/community/index.html", + "cn/community/index.md", + ) + for swapped_relative in ( + "community/index.html", + "_print/community/index.html", + "cn/community/index.html", + "cn/_print/community/index.html", + ): + with self.subTest(output=swapped_relative), tempfile.TemporaryDirectory( + prefix="community-role-output-" + ) as directory: + destination = pathlib.Path(directory) + for relative in outputs: + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.site / relative, target) + path = destination / swapped_relative + rendered = path.read_text(encoding="utf-8") + match = re.search( + r'(]*data-community-role=(?:"pmc"|pmc)[^>]*>.*?)' + r"(\s*)" + r'(]*data-community-role=(?:"committers"|committers)[^>]*>.*?)', + rendered, + flags=re.DOTALL, + ) + self.assertIsNotNone(match) + rendered = ( + rendered[: match.start()] + + match.group(3) + + match.group(2) + + match.group(1) + + rendered[match.end() :] + ) + path.write_text(rendered, encoding="utf-8") + with self.assertRaisesRegex(roster.RosterError, "role section order drift"): + roster.validate_rendered_outputs(destination) + + def test_artifact_validator_rejects_plain_text_profile_urls(self): + with tempfile.TemporaryDirectory(prefix="community-fake-output-") as directory: + destination = pathlib.Path(directory) + roles = json.loads(roster.ROSTER_PATH.read_text())["roles"] + for relative in ( + "community/index.html", + "_print/community/index.html", + "cn/community/index.html", + "cn/_print/community/index.html", + ): + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '
' + + " ".join(person["profile_url"] for person in roles["pmc"]) + + '
' + + " ".join(person["profile_url"] for person in roles["committers"]) + + "
" + ) + for relative, title in ( + ("community/index.md", "Project members"), + ("cn/community/index.md", "项目成员"), + ): + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"## {title}\n\n### PMC\n" + + "\n".join(person["profile_url"] for person in roles["pmc"]) + + "\n\n### Committers\n" + + "\n".join(person["profile_url"] for person in roles["committers"]) + ) + with self.assertRaisesRegex(roster.RosterError, "link parity drift"): + roster.validate_rendered_outputs(destination) + + def test_fixed_metadata_is_present_in_actual_offline_indexes(self): + fixture = json.loads( + (ROOT / "scripts/fixtures/community_search_queries.json").read_text(encoding="utf-8") + ) + self.assertEqual(24, len(fixture)) + self.assertEqual(24, len({(item["locale"], item["query"]) for item in fixture})) + for language in ("en", "cn"): + indexes = list(self.site.glob(f"offline-search-index.{language}.*.json")) + self.assertEqual(1, len(indexes)) + records = {record["ref"]: record for record in json.loads(indexes[0].read_text())} + for item in (entry for entry in fixture if entry["locale"] == language): + ref = item["expected_ref"] + self.assertIn(ref, records) + self.assertTrue(records[ref]["keywords"], ref) + self.assertGreater(records[ref]["boost"], 1, ref) + normalized_query = item["query"].casefold() + searchable = " ".join( + [records[ref]["title"], *records[ref]["keywords"]] + ).casefold() + self.assertIn(normalized_query, searchable, ref) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_download_data.py b/scripts/test_download_data.py new file mode 100644 index 0000000000..5ac7123c46 --- /dev/null +++ b/scripts/test_download_data.py @@ -0,0 +1,217 @@ +import json +import os +import pathlib +import re +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DATA = ROOT / "data" / "downloads" / "asf.json" +PARTIAL = ROOT / "layouts" / "_partials" / "asf-downloads.html" +PAGES = ( + ROOT / "content" / "en" / "docs" / "download" / "download.md", + ROOT / "content" / "cn" / "docs" / "download" / "download.md", +) +I18N = (ROOT / "i18n" / "en.yaml", ROOT / "i18n" / "zh-CN.yaml") +PUBLIC_DIR = pathlib.Path(os.environ["DOWNLOAD_PUBLIC_DIR"]) if os.environ.get("DOWNLOAD_PUBLIC_DIR") else None + +# Exact dist state, verified against +# https://downloads.apache.org/hugegraph// on 2026-09-13. The data +# file must derive exactly these artifacts; a new release edits both the data +# file and this expectation with a fresh dist listing. +EXPECTED_ARTIFACTS = { + "1.7.0": { + "apache-hugegraph-incubating-1.7.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.7.0.tar.gz", + "apache-hugegraph-incubating-1.7.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz", + "apache-hugegraph-ai-incubating-1.7.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.7.0-src.tar.gz", + }, + "1.5.0": { + "apache-hugegraph-incubating-1.5.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.5.0.tar.gz", + "apache-hugegraph-incubating-1.5.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz", + "apache-hugegraph-ai-incubating-1.5.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.5.0-src.tar.gz", + }, + "1.3.0": { + "apache-hugegraph-incubating-1.3.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.3.0.tar.gz", + "apache-hugegraph-incubating-1.3.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz", + "apache-hugegraph-ai-incubating-1.3.0-src.tar.gz", + "apache-hugegraph-commons-incubating-1.3.0-src.tar.gz", + }, + "1.2.0": { + "apache-hugegraph-incubating-1.2.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.2.0.tar.gz", + "apache-hugegraph-incubating-1.2.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.2.0-src.tar.gz", + "apache-hugegraph-commons-incubating-1.2.0-src.tar.gz", + }, + "1.0.0": { + "apache-hugegraph-incubating-1.0.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.0.0.tar.gz", + "apache-hugegraph-computer-incubating-1.0.0.tar.gz", + "apache-hugegraph-incubating-1.0.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.0.0-src.tar.gz", + "apache-hugegraph-commons-incubating-1.0.0-src.tar.gz", + }, +} + +FIXED_I18N_KEYS = ( + "ui_assets_download", + "download_release_version", + "download_release_date", + "download_release_notes", + "download_table_component", + "download_table_type", + "download_table_mirror", + "download_type_binary", + "download_type_source", + "download_asf_note", +) + + +def load_data() -> dict: + with DATA.open(encoding="utf-8") as handle: + return json.load(handle) + + +def derive_files(release: dict, components: dict) -> set[str]: + files = set() + infix = "-incubating" if release["incubating"] else "" + for kind, suffix in (("binary", ""), ("source", "-src")): + for component_id in release.get(kind, []): + prefix = components[component_id]["prefix"] + files.add(f"{prefix}{infix}-{release['version']}{suffix}.tar.gz") + return files + + +def i18n_keys(path: pathlib.Path) -> set[str]: + keys = set() + for line in path.read_text(encoding="utf-8").splitlines(): + if line and not line.startswith(("#", " ")) and ":" in line: + keys.add(line.split(":", 1)[0].strip()) + return keys + + +class DownloadDataTest(unittest.TestCase): + def setUp(self) -> None: + self.data = load_data() + + def test_schema_and_release_ordering(self) -> None: + data = self.data + self.assertEqual( + set(data), {"$comment", "dist_path", "components", "releases"} + ) + self.assertEqual(data["dist_path"], "hugegraph") + label_keys = [c["label_key"] for c in data["components"].values()] + self.assertEqual(len(label_keys), len(set(label_keys))) + for component_id, component in data["components"].items(): + self.assertRegex(component_id, r"^[a-z][a-z0-9]*\Z") + self.assertRegex(component["prefix"], r"^apache-hugegraph(-[a-z]+)?\Z") + self.assertRegex(component["label_key"], r"^download_component_[a-z]+\Z") + releases = data["releases"] + versions = [release["version"] for release in releases] + self.assertEqual(versions, sorted(versions, key=lambda v: tuple(map(int, v.split("."))), reverse=True)) + self.assertEqual(len(versions), len(set(versions))) + self.assertEqual(sum(1 for release in releases if release.get("latest")), 1) + self.assertTrue(releases[0].get("latest"), "the newest release must be the latest") + for release in releases: + self.assertRegex(release["version"], r"^\d+\.\d+\.\d+\Z") + self.assertRegex(release["date"], r"^\d{4}-\d{2}-\d{2}\Z") + self.assertIsInstance(release["incubating"], bool) + for kind in ("binary", "source"): + ids = release.get(kind, []) + self.assertTrue(ids, f"{release['version']} has no {kind} artifacts") + self.assertEqual(len(ids), len(set(ids))) + for component_id in ids: + self.assertIn(component_id, data["components"]) + + def test_derived_artifacts_match_the_verified_dist_listing(self) -> None: + data = self.data + derived = { + release["version"]: derive_files(release, data["components"]) + for release in data["releases"] + } + self.assertEqual(derived, EXPECTED_ARTIFACTS) + total = sum(len(files) for files in derived.values()) + self.assertEqual(total, 31) + + def test_derived_urls_are_well_formed(self) -> None: + data = self.data + pattern = re.compile(r"^https://[a-z.]+/[A-Za-z0-9./?=_-]+$") + for release in data["releases"]: + for file in derive_files(release, data["components"]): + urls = ( + f"https://www.apache.org/dyn/closer.lua/{data['dist_path']}/" + f"{release['version']}/{file}?action=download", + f"https://downloads.apache.org/{data['dist_path']}/" + f"{release['version']}/{file}.asc", + f"https://downloads.apache.org/{data['dist_path']}/" + f"{release['version']}/{file}.sha512", + ) + for url in urls: + self.assertRegex(url, pattern) + + def test_pages_render_from_data_not_hardcoded_tables(self) -> None: + for page in PAGES: + text = page.read_text(encoding="utf-8") + self.assertIn("{{< asf-downloads latest >}}", text, page) + self.assertIn("{{< asf-downloads archived >}}", text, page) + self.assertNotIn("closer.lua", text, page) + self.assertNotIn(".tar.gz", text, page) + artifact_links = re.findall( + r"downloads\.apache\.org/hugegraph/\d", text + ) + self.assertEqual(artifact_links, [], page) + + def test_partial_derives_urls_with_the_same_tokens(self) -> None: + # The partial re-derives the same filenames and URLs in Go templates; + # lock its literal format tokens to this module's derivation so the + # two cannot drift apart silently. + template = PARTIAL.read_text(encoding="utf-8") + for token in ( + '"-incubating" ""', + '"-src" ""', + '"%s%s-%s%s.tar.gz" $component.prefix $infix $version $suffix', + 'https://www.apache.org/dyn/closer.lua/%s/%s/%s?action=download', + 'https://downloads.apache.org/%s/%s/%s.asc', + 'https://downloads.apache.org/%s/%s/%s.sha512', + ): + self.assertIn(token, template) + + def test_i18n_catalogues_carry_every_label(self) -> None: + data = self.data + required = set(FIXED_I18N_KEYS) + for component in data["components"].values(): + required.add(component["label_key"]) + for catalogue in I18N: + missing = required - i18n_keys(catalogue) + self.assertEqual(missing, set(), catalogue) + + def test_rendered_download_pages_have_verified_rows(self) -> None: + if PUBLIC_DIR is None: + self.skipTest("set DOWNLOAD_PUBLIC_DIR after an aggregate build") + for relative in ("docs/download/download/index.html", "cn/docs/download/download/index.html"): + page = PUBLIC_DIR / relative + self.assertTrue(page.is_file(), page) + text = page.read_text(encoding="utf-8") + self.assertIn("hg-asf-release", text, page) + self.assertIn("1.7.0", text, page) + for version, expected_files in EXPECTED_ARTIFACTS.items(): + self.assertIn(f"hugegraph-{version}-release-notes", text, page) + for filename in expected_files: + self.assertIn(filename, text, page) + self.assertIn(f"/dyn/closer.lua/hugegraph/{version}/{filename}?action=download", text, page) + self.assertIn(f"downloads.apache.org/hugegraph/{version}/{filename}.asc", text, page) + self.assertIn(f"downloads.apache.org/hugegraph/{version}/{filename}.sha512", text, page) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/versioning.py b/scripts/versioning.py index 1b30f3beb2..84d9a2d5fc 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -123,7 +123,7 @@ DOCS_NAV_GROUP_IDS = ("start", "components", "develop", "operate", "reference") DOCS_NAV_GROUP_TITLES = { "en": ("Get Started", "Components", "Develop", "Operate", "Reference"), - "cn": ("开始", "组件", "开发", "运维", "参考"), + "cn": ("开始", "组件", "开发", "配置", "参考"), } DOCS_NAV_EXPECTED_STATS = { "latest": { diff --git a/static/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp b/static/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp new file mode 100644 index 0000000000000000000000000000000000000000..99608aba000842eac83a5a1944ecf339b8bdcdb2 GIT binary patch literal 1170 zcmV;D1a13LNk&GB1ONb6MM6+kP&god1ONa~833IDDu4ih06vjIoJu95qM@jix?r#p z31yu0ZV zN9>omMY#$BZP-lkQ=c%%_Th1bxqfm>L}u-P>^u#pUq7!(AkHvktW!HiQZ z?VMzly}SpeOImI`pkCsF8*Vws8k7RzNlr`85_ApV+rZooHIg>W+w;_ELOHp@U(idY zX&X-6sNv4BaKsdksPL!5o|0g@`vb7bZSZ90)t$!NwK$$bjL5~`#x$Lp?@^pjb2%g5 zF9nx{Xg`Ky^*As&YPs6Ly)vDhmV-i2OM?A5k?pO8Gh$Q1u(A+c4?fb89dN6#K*$kF z{D{MMsT74^61tBh^MH;b-x9ia?s4)62KNS`%u!BaC6COhXLXim*7k9g3x;Im54CXn z!T$Dqd1Mmx%x28=olz|iG|Hge%|wo12H~xE10YB!4gQL?+?Y7pT}RB0w=VFCPMjz( zKc+--_wW*Tu=ot7f;{Cu&Jh>K$noeigTQ9kalt7EEQ@53KT2W>c;7vaykoiPtMMCc zR+ZLv#tW&>a)Er=;eO-!L*#jGn?rtXNR~OxDlrlA)ewSaP(L)2*Q788ZgKd46cs&l zbm4!0dm1KTKoAWS2=SP~=$bQIh*Y8isabsD=}oRgb>1Go=z%$6=bKm?@#VGn^`Qm-FF!4RUQ{X%#;uFQP=8z*X z|017jHSyj0ecu?$lGl_pUR|I`4=9~v4(xx$r})LkGarh_4`=cg{~QR9Nc{qWlI-+j zkQ~Y?iv2jSA+bk(W}lb1PI!Hy7RqKB!eF#=OYn$}10teRl$xxpYDJGrnSp|suXX)X zW^K*rQ2yzQ$=Vccpe8@P7ap-t_ zd7goCxsq@Pb@35)=LR`Lu1rTpT=OVXqc(eil{&jzvB`>gD7IwvmT)9Y=q~mFk<zm=vH@IobaFz%`z@&1Pm&M^kkCK?0HUBb_W%F@ literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp b/static/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp new file mode 100644 index 0000000000000000000000000000000000000000..b386f5f0278abf068d7cbc01652203937ef5dfc2 GIT binary patch literal 1104 zcmV-W1h4y2Nk&FU1ONb6MM6+kP&gnw1ONcA6#$(9Du4ih06vjMpGu{qBO#_1sfe%= z31>uiAx6F?2*PWg|Jo95aIau8orlLFZuDy##_yqSgPxilAp*VEu}9l>y!JTc=wVv`#j-igxM7C|bWLX=in}k2{NGMxA z0092)6$1#gQvdN6m0q$^DmtNds=09=py7@#wZz5D>!F;lrcjT0kjzUA_pAs<2JqPL zQmNTpPxBxtvbQ2u?l8X|%;jn2JWYb7@$ckoQJvGvXH)*0v+cgE(MmY6y0RIQD+CTQ z5Q3TI-B^Krl33Qp<~e;3&;S4c0wB5&4nA^nG{YZ>O?m6@6{5fj)J@E@Nj2J%q*kTW zNaR#AIWq{n(ADBf)LCo5ysp!k<~n$0o1v3V`vLQAUH3>;O^23*Oh~ zimV>SAl)wQ`x#Z>3BB?kh!rvn)1|CyEcKm?co$u==)fp3RoRvG+bA!O)Bw#l5TClE4#a!Qr z^LV2h|3vLoU14frve9_+51WN2{~0(31i|sN}ZuhUT!z>DdS&Jb%0}*8Xm$t z94OCdPRlO7#gdnXd=l+M9e*!rf3p}BK!M*e{e6QbKs1RY^qyS6{fr16R6KHc<4r%! zf9ihxOgrd+t&1O}__|ov^@X~*a|cxIT>V5@zEfD(KQL*WA#6n!6cPIw%k@o#D(xe% zI*4XlMjqv9d-eiA4Y>Ig1MN@dck-~D;J+j&A|tf)ccUpfFK9{T-uOwB2pa`E`fIB)%B`>ciCIYhOFaQ8Ia2MwQ literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp b/static/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp new file mode 100644 index 0000000000000000000000000000000000000000..e7e43482f31a439863751155ec76c3dedd9144f7 GIT binary patch literal 3224 zcmV;J3}^FFNk&GH3;+OEMM6+kP&goj3;+PoF94kZDu4ih06vjCm`Ej~qMe%mwJRd-q9ZYdFoANT1o?cjP!od+t<6cE%vHl zJDX67TE-F3OtCS4I$t*44SmOapGHV^;$Pg@(8&~z1Y@72KIs@OAbBY4pyMw9oQGL8 z;WJGg_xw+IPz*K97q(@Q*gtMqI^kw*D?Z_T&KOkt`LTSPoAsQItP`TMhqldqk?Lo` z*>&uFGOqQ>pX_;4P9z41%pV`Z9C zu1Og3xUo>p>yM0`)up`pF;sBmCro7a*XXkbOL^Hdq)bZ6JF1VMHV7q7pPY{AyPu#a z3Zko(BxlZa13#KlbM(sY!!owhrP#nDDr3aO(NjUS=qCS+kTOzP4{2zgeEf1y4c3l2 z{1-%FQi-X40rEAo2fqvez`D*~lQ>5|qQw61;>1A3<;U1Q)as3=`Cuy8X$M?xD$u3x zJOBXx`c%xsvfC&VmHP}P3C%ts#aJydT@VIQ{gh(nalfeoCo^b8_KK+Ub2NJ=aljg& zZ%~fX(hHQegm>1GXUPv@4-X5F-#wJ>xl$U1gV>YJ4wG zUcJU(o1K)q!2DV`0uF##@txneU-}=Np^sL)?kD zp9P$R@*2CU=vU|?KGZ#(o#wkOHN6yarnl17K>LZlDyBS~*Mi*j;GWCG#aj$4+yk!0 zq)T&gz3OdKJxS`b?XHm((f!)0!t%8SXGRFw#e^jqH0I9W<6N4$qIIz03&pl+_OIk6 zfw09(kYSAwJ>zi~(kR}(IkiToXslB*-(tlzqWtSG9cBn#HpkHZ^MXO}1q;_6=88k) zwiVcGxjzb*5SO}YdjW~`ZM%8I&;gK2Hs4KgOiK?T;QHnRk&N_BsR9w+Ya(yR`_l?O z1Uy2TmO1u-a`3C^T&d*Z?lCX2D~)TyTy7Q0m#uqi)3x@qau!Ta7% zX3EBHd>;DemkWD@#{+_bY7_~3Cj}pA?`T=0GwLH~76YFTF9UM-j56JJTqR5ld6F#R z%_M;r6sx{YUX(hl6d^2d`~C}Iz!nQUfB9)arWIJt>8RIYKIr-0qj_OHW+$SUr@RkVB?35C8nP^S5oc)WR!q1e4#Bh7k-f(0@_Gcx4DoS|?c7#q z0p|@;K@?NYIqz(EOMkm|&G<17Z2BO-gbnJXh-mZTb^=4DiPoT*1AMhx^1y5=DYJfY zed5>L%CZKOL~P?*>!W3V#J#P>Qjy?{u4-3Mp6A+AKxL~XwiHs{EvwV$x?-NF$$1UP zVRYs0=Wq#G$4FBT!UXS3jdEKX~vBcxVgMajQYs z?#9VUl5&2`-MPT@&*O{B2tJWGlh#IMyGpClkZ~%$S`4SYiD}ct&EZpq<||OP2_$JZ zmDxHhjx<1oZ7#<|OHDl$SvO2=V(USlsTxCZ1WsB21kN6JSG_8@Y{CRTB@2`jB^V1} zvsV;m&tMyhyvt*CwKF167=5kXKVn!y-}E(HYJzM8oz+-~>c&O9Se+YMa0NZ(5|+tq z>o8Kv=^85eO;@Ugmg8j4-b8SM3<-pLKLMJWeehL7L5?x*`uY%am9n>mn^={#ZxcBz ztSUuON@r_Mt;p|RK>Bb|OSjdMk`x^zwxpOoaGbP&nN zviQjYJIdKxu6dEFG0OwMa6E&9QU-A5Yp)^QYfjN=)D2dRH+59@zwj#%@o@8alXJn^lggV z*po$xJdrj$WvP^klpQr4#L+ywFe`*5Acm#g#>^7HVE&!|pDy(uVR z!mZ(eh<91S$aLFYlcPY7N_qHLPnW_o7+y>v*Q87N;kMD=0@d>&x&1G=IXKTzUf2)6 zouI2eu4Ma-<`>uQ4E&^>YP6eoLp5#(N=-GJiG-wa!HaUm#@@t-vjDy0CBOHlNwij#eq&v6wg(r$T`5ftI}1e zgcI(gJt-3Z%fV_0B&Qb<&RORZt|m!f3`r3$3lfaRs^dT-$ZUOp&j1tjlX)2Y>I z-;@IJ)0r=$Dl;XLm*xg9dSpZy!^bYxE6Tl}t7w}!Lsz1VoJbfUXxq_=&8y1hx`T4=vIi(Z)MR-ow~`+3xNwv!{U4E!2-r!ZE7O=lof3%IG)`wa?30Zf&G^^y1ds?Zv7t$b3toUoFTw5#AMS1` z@ql|tA*~>tU{emm=KOf^Ng_?^EDCt7@heoJx9t|r+@zOZe+lqzNT&D40Q~-XwUmHG zlcmXcnfCbZSH%;7Zuht=iN+QdQs-!86>&OdLf=s5ZR=b0$VemX`b@QAGjEVR=SHJc z*IkDb7lr>41Lkt5YibY@!sL;d24=B!)V`gEjBlJR4eVOWk#bZq!t}BiY0HEstTgN0 zA?*L(x86tsBn;RZS?J7_>erg5a?HK*Qm>k|m5|;tPa(WJm;Sz080fhV#s^J6o-`oU z)~YGvCmPMTR-g-m&;i+%qQ7LI(MZl5KHiLS1oXkpuL7P8HQV_d`fH_^i>`E9%=mxLR`gTOaAIr0o4H<4rBrOh z^w_eVeN(hoJfV?^f$Gs4X8VE!suC~O^*K+zKSeu|g+tQ#T%G)LvD&3OFV+9dmwsrv&Y1w)ahCDbo!Wm3i@BxI=&xqf z@vCt9=^^j`9?VJHL|+93^>O5N*%lrx&DJ!HzEngUgmlf@kP^I~T{@2&d^O)8)!n7#xqwq=I9`BvgETZ0#vSdiDv6?j;s4!3g{X$dj7M+ywZhiFLir@S3 z{TTW9Qn1ILun2p49&sBSgQ#tymvC7yeV=lB-pcJgYev(8b|hWim^>@V3wcy@7poC#{AktU9!#vuQNrWGgh|GDN$Gay zhqe)iFme@ty@17YJF?mR^_EQAz6Xu}-98mxuuq|p4Y8fHR|A;?T=|E^S#Mm5gFjlc z4Jjc1qI#n^-rGO+ffZzO5@9J_{rEcV4{l-gm=Wsvf~0D)C>k}x!XaWsq$=Nf93kbC zsKil~m_yTx6?K7KbJ0{Cx%7dL6iFNE-ME43*zWCRv0S=Mfe`pm&AGf&8RzJGa1#6* zmXym*7xgGE5)dI_Yr%`wU|Ab2TGoAxzKgRz9oq^#zj**^M?1HXZ$ZOLvSY0IUwY)~ K+G1g#&p-gIM`MTp literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp b/static/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp new file mode 100644 index 0000000000000000000000000000000000000000..75a3bcacd69ebfc92970f64b06acfc9727003e48 GIT binary patch literal 2002 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 literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp b/static/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp new file mode 100644 index 0000000000000000000000000000000000000000..895eb853186f89772837fc18505bfbdf9ee2b8a3 GIT binary patch literal 3880 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 literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp b/static/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp new file mode 100644 index 0000000000000000000000000000000000000000..f28c4645561f56d8ca810885e59b400718c7901f GIT binary patch literal 3654 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�xmKb60iF3xu9t`yRxLhS;E}296WKvh;g?vg!R@lD!hEoI&_J(sIysSL!^>-MVIb=;%o|f!i@aC)koc8R1xLB#w Yr-oVWS>tNdT@kU^SU~6?0K3>w06e=Uod5s; literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp b/static/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp new file mode 100644 index 0000000000000000000000000000000000000000..09a262871a7d3a6f86da327aa7ca85bf66a7bdac GIT binary patch literal 376 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 MV52(#tN;K20FM~3t^fc4 literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp b/static/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp new file mode 100644 index 0000000000000000000000000000000000000000..a25001e7898a6c8eadd412eb3135655eca9c906d GIT binary patch literal 3602 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 YQYFY3ApBM!tIKrnrzj~lp+jf@0GZ3=L;wH) literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp b/static/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp new file mode 100644 index 0000000000000000000000000000000000000000..2169234a1f3253065fc14c5f4c7940ca38135054 GIT binary patch literal 2910 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 Ik1?PC0FP<6vj6}9 literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp b/static/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp new file mode 100644 index 0000000000000000000000000000000000000000..875ebfdc594ca1ef42d7338fd514b7f283debecf GIT binary patch literal 2858 zcmV+_3)S>eNk&E@3jhFDMM6+kP&gnK3jhF6E&!bYDu4ih06vjEnMoz1A|xi3EL00_U|8LEIzN7w&&_4iGruKH=^dhnYLK0k$X|ARmZH#=@z+~{OB zP#(rpI&x#=O-Av2V$twa7Oa1TX3l*Qg5~Ob?coxyA=s!B0Q(!fu6O|c`D&ycp@s)W zSHmpe`g9Q0cH%CVmfj7rwdnXQ7GlWc7pJ#w&A|v}<3}5JJBNWGIhth6mGCh2iT9dh z88rLy@p=o;!PMayRaGEQ2^@9m7cT&gD3#nvE5}$DY0>7M3X=7i6t}~o*4M#+k+!K9 zd3Z4SwC64*201_KxZ&tkARoA~%nN&8R#`chB27TCvNHBa5H6&Gh%2At+ERS(O8zbI-Ihk3Yt`0HCyMXU7dPB|N+9%#wE` zOq_4vE2jHPSRRYurP&vSTEQCcugVdqJ-3VbU)oy zFmEs;P+JTP=D&e)$)Sz1=%S?*7Y5aE0@?_1+4HR~&gZpVHHD)C{V+lvCGdP}%sZbU z{v_!>T>E2sKh3QO1Gfk*{$FQKmR31$J?96Ai?cBoaxgH?qB-MS-{z-w`ZmE zg{~2Xf9e1mU@tZIvs-~f3$0#VH*}?fKI_K5YzGn$M4{45d=QogK%@Qpo^6BWzXpAb z`$_?^pO#(RTX(KMixzBJc1;^5Cb!}-QMzeia0Hc}#bb^2mFnsSF_Kp8gOhR`12ZoN(+|Mu`19-r~<|ow>w1&>;au<$~<-;v)U|uVgLxg zyuTKhpAoCBg}O#jnHB3nx>;Z6JIFPq*Z(3tptG!$5=Kdp=P{d(1JY_H6WCKM@FV4v zbvfRnM7v$LBzAxKu`$@eAW6xn)NtVSDV-1{s9{*Ht@4@YU@MqW8x$%w8}D|nu^jYD zL3ksC6oi~`y3l$}hNl9ApH>N%vF~?!G$92DD&gwgZ2&Bb6XoH(`ONe2IC5ZF&kA=3>&s8&7xMEO~uEL;PbhVFOE$#h32Ei>x=H|zXjB` zpGOoRLaO?z)`)q%aY9w0Y->?>6DHd+Eg&VufE%+3Sj4;k%81JN=nHVXe+NaY33JND zw)H9asyW8Q8FgZ{d8q>BeMm7ZId{p~-h)%ro|`!xlE=O47oh^@&6&KoQH)5jn=ag< z+KY;g&pZ2P>cGNC$)%_yy!zV}Y0RbVx@>U38_<@{LI!ckBqDVe?$3V}B^z$uA75al zskC}P(C(YsOyofV7AL)VxwCnX4#wwvqq_fLXHWKQ!T%8Y-^uOqhNoz7q^pX0aKf=-G13;? z>?VUO)TEpC_wJP;0Bi;8eZML{)!twrS2(C2{*lqacS5YvNGIThK%23PjSf zYa=b#9DCE+HbSdMGAHxSr-O29qGa2ZWGc@G*rO+VTwsR!vf-nW+LN?p=y&8ySFBhn#!#oWErO66t%1Mep%cY_?eZ7(3gK#b7+Cn`KVIo^KSEF0 z5YWK=IGd{cgcSX=A|<;43~)&^2KY{RD;mK&Y2yWEX*!H!KZh623eN&itrwiN-AHd2b)6KNuq z6P7R*L=h%)IpzYT)ta}IiC|Y0=o8|HaZn9Er1Dr>MnGN*X{R(aV4^0@>9h%4xZNkE z65O^1*^)jmD$awmZtY1H{%GZLGoM_G70PbhnT>J~f|8SA5=tY|?}(|IRau}0E8rE{KJm9Umx&Zb zU&+`$+;i(4?(M;9;Jnm{a7k`N7gX;ouZj&uaE9m@cOWV)BFJkzUQK<}_eVlEBGa){ z1e?Mwj~%;@YYcPax9S1KahR5+L4sb<=r+j$FBm@sw2{mA`x{qvXB3^3EyRAJrM5U6 zW)@rE?3U6+I1WaZU7N-kn=unXck3%9204v>#MFH4d+vH;3IhDARrTmF#&v>8F5Hoi zAny3~v^vdQLTa`|3+AqHy=`UUSL3nFm~of*wd6EP={@~A^xo9y8_ ziUS8Zt>HLDG2WW(-fpX@qHgx(3n>|uBtmIJgAYKk*HfvW0eX)t)$4!<5+YkwNkfBr zs=z8l$ye2`6UI+Of}Rh9>_?fL6zO61gK8yF{MURN*l# zc+$=_78u%3!UArA!*cyCf<$UL_H0&<)mbuxnN9L3D_M4xx~p2bHideyZTOQGDxenv z|I{6zKC4S|UbkAcN-IxR9vS~@lEPTA6sP9c10areg3mpsApYeOsY$_M^s45F-Te2H zu0cx{^s}0bb@3%gZe59XwZ5-(<8;k~P>M)-$H7!SukR21{$7LuDNJ4?*C_9mWkmdW zdlr3h>$=LX_mU)ls{j;(RpJ;jPNl9JPjiFY>pq$pad=?@<-X4VdtBqY+0%EJk@&?S zntTLKBo8b~N%QIihks?^-hkKBYvm2g%pb_TZWT-j`e;)O)n^%@9-?_rAi?gPtP6Hp IbdfT60Da7hp8x;= literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp b/static/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp new file mode 100644 index 0000000000000000000000000000000000000000..196d52cdc8db5a49a517b5ebb6f22976f822b184 GIT binary patch literal 2662 zcmV-s3Yqm%Nk&Fq3IG6CMM6+kP&gn`3IG5wD*&AVDu4ih06vjAmq?|fA|Wmk?f9?~ z2|yT~4g+Bj5z>`$GHG=u`E+X?>B_*z?a|56)ll zJ*0og@~`!8PM5Ne+mB>rW^BQmXRY4QIC@cjKkvjdg}Pu1k{#5~bq7fsnoOx%S$@;z z{pM+jGen+3$5}|LSIi#Z=qPCF9!J@UlH$gI+KMuW!c0EvwE~0B3D4nK>=2*-r>l$3 zfy3_7!;WTSWwUn2i+{T@XEyO(_cH2Z+3^=Bax=xR|14__&V<6^QC=eI{=$(WFW{B0 z>@tr~i{Q|eC?;SdHyY_5>vo^MgwKg2<77Z^ac)v<1y(uN{+t|}xeZ0_a*0eeZoVk~ z(@~e`Sv5dX!2i<~lK-Y=0s4g@l|L8mC)GRVI4FjwpgIG~sAFqg`11;X1pd;xJ{5;v z*f5H>z7q8?6TBdJmGz!_0092$>eLH|g^?Lj?yx%Vz|!`yfI*RFm71gYT#VrX0Gw4K z3A@wwZ-oA7^`#YV9{GMIuor}ngttN6?`mQAW)7O*+A^j>{WFktP(>0hte!uJ6w0! z0@ctBeb?rlqOMfIal4FaByI6vN(&ke@*~AQs0r^JAZ#qkgOS2_{?34J^>52lG6CYd zrv>Q3rCL>kSsXWpiPM}_P$ffAYQEJPt$7pjqG;1*$QO?x#WYjy(Qi*^76l-ULveE>*rFx(^Xlo$L`m3c{?k?{ zPVET;R0{gwQWtrzM|dhoa$M(5WFGZa8KKJoxL?_`N7ij{Y37AllNwjxgpUiBj~v-^ zfi1S0g)w>8RO1XDI9S`xffwfq=tJ;D;$p+INv`(k3_}5GQ1S%R%Uu(U%4xPya06A+ z=_0+ud8um?vERFmIUU2zv>3$gLL$BV^~W)l_J%{-JLto0w`XM3Q*R11aXEbZ3^LKt zZAx03LPj#DK98^kF(WT71tf$hZ_iW8-#KCgbMscAeNYIWKjb!08f?z86#`=V9=^oXZV{wK_;P@ z36y8cA3iUT~&g0{Yz1*7tEgZBwN!?5^_Eoy<0H35+`Mq%O)*V?K)2xtdZAI{GiA_l~wF}#&pz5%kl#-@4Fqv z4*A$~@Me#c(MhuLI%04kAH(;>N$)vMDR10FAhwzQ4awm9Hz@q_`y38h5>mtaGBobk z_3dNnmUhS)z34)W@9IHT0{i{vxj2-UtGlU@yg3;Y-`bIqU-?46VEiiClsBsDNt1^~ z7b>yQl2&X-$4ETi_=rtU_TqJFBmVm});NnBetEBMH<614j=qYHe>rj2UzoPIBArM!2TGOy?8>;OQJ0MB!|{g#80i_ls3j-*e= zKRe6*goGr32J<3mZ&!0M1!SIApPn|BF5%K3>y4Y{`Uc2&n{}_CUoq#;PHN>L+8K#c zOSg4#_yT13Esnx-1ftl)B4XYrOdm)H-(X#k#wd@94pQ*vE8L^&SwZdA69q?=oa=av z{!KQ8-?vUUhZI!;t(aXjsLFP5DZac27+0KzyS%} z(TpTh9|U^bU_ApnoT%3eT}OB%Pj$7S^B*N;2Ux_~rDD!N+5(e%5dA4Q|Ntw4STS z4$JwUzK|6&X6$0s?YZHlS;1i`BOYqb!sv_`GxZOJ(REJq2jfJh5TA@hyeu%dbDPQ5 zNexqyAS3R%PD+iKTfd=ZgkTQ3^RRunh26`rM2cl5WuFS|1oPYwcjtg-N*)Us+HX?f@D#@`X-cB? z>9kK*bS40Qk`blFSgSfqHpDyE`4Z#);$i6gK-Gt=#=WT(2MCR~@($6dEXZYdJ5GW@ z%i{=jrk_oO65sIENj5{PwU>&J(6_LjUlt(ZtYzs!#tz<)Cxy%-_=X<(lVgTi+q#i7 zIX{uVR*1i-AbO)w6IsJjEPKeEW_`;OxZ>|Rhe#XR*W99)p8gnSj+q;x+Nc}gyv!iO zFi5$pIb43GZ|2jUYZk+mc|#`UyubR7ftEG;g5rg@dQ0fey()@VlQiZY)UMQpHl{<+ zcGwPXMr242W$@ZTNxVS+=(sQC>h17wFW{OSo$OMNZyVr;6#jnG@(7B& zSD}7;>2z=&kU#-xeM9h?zo~n3zZ80_s^eoe(+p6N`*+2RTUTDJbC$bh!A$yxvSz9& z%AneF`%aUl8rB5EtqvgPNII>$r}O4I45H+IVtu4YoB|<9)`d*7AJ?1J6GAyb?f2Fg zpm{cVK?A)z*S?ns~z ziDUr2x0nl5nE5}$9&d4g=I$}1P4$wq1lm96d;{_Q@$YqgZZL1x)}ej7=Ue%{^L|Qd z5$$h2H?X)bz&-x@nRv6<5A~zcFZTWv|9=02>;d{K_BYv^L4URXy7nCM-S&~`GJqct zbZ&(O4c#HJGtOtKMjr63mVG%!vt$P>BzojzA55?CDE0s|;6JP8v7&6cp5Ieb)aF$k z9gy=UCi*;5R=0a@~){cjk{W^)I8sH z0=GFP|3y{rzSgn}MdEiqMlOJ^PwUd(eQNiFH#}sSc>8s0YT}F7JVBL=q#k(z>KSkt z%%q+1!4zi;(UXF}gCKD$s=~!2t<~8YUj-F5 zQ|_fn%F*33pBuEkUy@iQ;1<$AOMDl}^{>FVg|p7ba21YM%-aX$a9e@vNm&lEJc!$h zJ)umkr_6DBHhRobC4Uz-M=EwSk@Kjg4?Rep+!*=#Rl)I&lGk-xm#6O7we}PlrjKd3 zNa^s172Vo_bPoYJXmu9hA5sel`=Y%#-s7JIrX){+B|6MaRVA(4*k5J<0M(U%60>?c zu?u~Ff@a9(9Qo`n{BLpRsk%Pl&J1g>gQo62^K{0A@u_#v_kBQLc9^er*ybxp>r^&J zSlxZMxQn^z(tL=hPyAm4Sec9e2N|#(0?f?W4OenJM_6t9YezC?rcXZXdP7-Of|%$R zHcZ_m-=qL=YZo5w@f#fF#4%aqeYr%8CCOkwp$I z$~U|I4Qmqwi@t?U5d;%Xf*#;Jf2;e`H;PBtTu9BPqPjr?ep#zZ4RQYl)({S^YYF0R z#EU4DagsOBV>jKHGQx{C#T}~wnW{h`kUe^y6SBb~Dd~Le>MR|a0liI3*0K=MGO-?L zPh%V&Z%1MjH{M_ndNz~q^hItRQyA8BP;fgDo9{gk#&meb^v)}~ z_Ycs*@L{aBefu+4FZH$^(BLFyR+23WrSgMe9QN9Zv>x;b*^MNR#cN~?@Y*(F;Fbu_Fj2TNv2nHy>RczC4ea=bM zl#Su?Ko2J9Ra-Yb*dv`&EH7<#d@gKp43LkKrUizM$OoXN+zGr&w79Vsh-s5sx&39p zao~rOzK5DGw`9T_lst4;LzGAQUp!YF=Ndz!9&YJReSq{i=3rstCzLn&P#Pp6B#$uy~xw z$s@HdPVbfRlmpvNKDx8rq`uA1qebRDa~rp%@GhQ zs78Vz$;F2$shhd?v;f{?!Y8<+5y1v93o@b?CI4_JrU z)OqMb!aRMfdAPr+?;=}Hsv9#e<#=3LC=@n4Ku9y+)W%kc3_G_Krrt-giZwmeQ}=jU z;E{ZK9Tq{W^z@S;hca%$kBlYnn3p0B=9ty3Qb$~Ly1q~h4b$CX6w-vH z`(2rnXd54|VXGqd1t`B>TpKlbRy{F`n^z@ezo-aA1|9hgaiJU1cukSmgu94G%Cih# zH4#I)_&2~4_l&+Xet4%wG~%xH*#CTc@QcoAXE%M1>00jwvw&5v!v~AY3085YDn&4! z#{&ymDN0&e147Y_agu!3FQ6S9c#2Cib^l?Fo=3817&@uU64;d_PJ&iPWO5(JRMgpx zV%^ZABUj@D=cqLK9$A)Ym-}MXCHr;^ScB6HAUWrFJjS>;FsEH5`Kk*K*t6fLlR@TN z;wRMEmYJrC4Tm0}q$uUVqF-rlq~B-b#jrP=s+56R6iHy1SHv9GM?A0H@}8`!($@k~ zWh_amvmD34(9+eHuq=n}nn_|W5&S$#Dsyrmke7yj2%F~PR~*7tQb;ZT{9u|7o}p~T z@ZB08nO>m*O@@fFOZ!`f18n-jLX#*Dgqn1H5QXm8;|l95EbZ-}^|p;V_l_VWecg8>_07eycu_yv@CGyDs+Tpez z2#ec8>EIp3t!)-_Q2b4jF5cb5mG)F_HtVMR#AeCl^7p0UVLs8u+tC@(@qVl^0Lxr| z){=TjGJs>(1Ht8%*}H|5b^hriZ+F8%_UN`7Rsu*5K}cCJGMM*{1*k`M?2j0>u83~r zZ~t2RN3PX&B6$jMv#VPq(K# zSaW&ZINnPl4P^R>_}s-EYy`es%qu;%_=1XH`zzBI^od%oO{t^b@P~!B#Y~dpEM`^%*Den5vcAM zvD`VX7HT}SghOiLxm-Rxf7wR$h;7$rq9L0e+iRf&2jLy68VNx2DgNwjCU=Y13Y74t z!#v9Oxb#@2z@N5ge;&lgfvJQDB1Av+@@}2>!ybpN~CRzgHqxn`GP2R9mM}1;@b#WliC}KEJiAI|c)sF2P4oMHalOpja z%6cXsyfnB}q5o@NZj#A?GNj^y$A*B)7L~XL)e)wS7W&=iSxQBGqYPuK-Ude13N@H- znp+f`m8OJczTlF{h23#;s96=?`{D${q5a+{yIs^DZuH#{Ny$gD6*2`MJQ|7DQ;|oA z4;3bRrR>IAnY%UMq_zaNq@2rNZth#aYLDLUV1}-a&B~%sZZ! zRuH6svOiLavHo0{=K}26+c;VF!cp+rS7fjK^Pd1rf=iYtf?iVb!B68tbnw3wjbie- zu93A`#p?@k*=+1L*M*dmTB7iK`!FlI0C7|h!b2QJ7XPcmQv4Fm@jy|F0{S?eD{26e zl!q@?EPc{p_J^J6+Rl{iKq#`BRKIO8P}O!>hz2_zTM|=x5x@llqCw_K4PXzBcto6I z+Nn8StPO;Uq*;Wlons$eC#VQ;w8{#P!?ggn@aV+7&4z{H6L`v}qf|vEBVyvj3X|)<9lI3{)^56>Sd|3wPN+9PK_+gl#hM$k--mGQ znOBWgltN*;=DRk`fotB@QE{vDPnwsK_=Lz4ZB~1CIcL9P{fS|&HN|FmV{;KOm( z*T`|+O<|ygO_9M^iTOXlG>7$h0O;torl<)r=^@uEfAEC9(3Sw**@vlR7O(Pq0gEB% zmjgx&Kk?BCMRoHEig~K-4ttXBTX3aF?NVM*lT0TrNV=u!cbb8UdUnzO=!r*auMfMw zW9AS5DE3qSMn!}m`aqKCI|v1ag`E|J0#p0u%M-bO~h0JP$~W|%u_LzLH!Xz4bq6! zJj7~r27jjcrd(hQOqDx71p&?3#1bNPk5czNb`59G(`*=k0%cbZbVSxGn^pj0-y23R z?b{NLXz27(r43mgrgTLEjqalKwG#mmCD*2{7W=#X@9B!6p})`-Zd@hD*}1EwxYA7A zhA)SM(++B5)luhd5q!LYJHjxeVdoHby0HM>AE_muD->Fc1|?#LK$|9l@IudGXC7}g zAyLbGeAYUV>n2LAuzWys(G66GNFR!QwCs`XPZOlm5r~qMOpg|xlWau#5?`G-!RY<; zbjB4K_JUUnubSZVM|tL{-V-WKkSnvsi<-zYDp^oD-Rc3@wj;fls9*zomzo{BB#RDS zk$3DiUffYb@ZY%9l%+xlb9>}(ucM!k$XoMr|8;OwwShzkCQy8_()^3!0y~ z6?5}3!1<0KsG~w6;JjkI#Y9v3`|L-4Z&g$n0R*otqIhsLbvf`wwj`92;`49KXV0^c zrM+D9E5_^+Q72ag3}o&`DuTP_IYq>{zwykCc=l?%W`i^~%?q<86+4&#F}nE-Db^%Y zI>i5KH8r`dhVykUVYuYpdV7@cJ3i$Wpy|jCb;x}ELw8c@DxJe?SzS}*))#^zTB~K@ zqQi~@$w*66w#cR#*6KO)ziLU7lZfya+`D=V*o$&2ZfJUq9v2Oap4M zu#L~6polgo|2;x@%&A>vt4`NXD|*O?b?b8Y+doecMS2NzOT}j5j;)#DfQP7DCXvL3 zx9`!MOQbhz`4H`+B9vYbIvxzvPAIa{w-VL*dp`{Cz1mT6t5cX3JFA=H&`P;uaV$Ce zTfYTOI;st9@P17DzY%9DzC9R+K zM-asOxwZz&Hmo`{?zyK~en77ZYRVQs$(6fOR5V9YkHfC*O7+C2>H1dsSNV$)!pc^5 zOL8L)0>cr*5@oB>q`>G9yv^q|tiLR*jl#^xMOG>`Jw|4^xF%?9QK{$6Dul=IVBjO%J ztoI{l&NSxL36|5&c0@R0(UuqPXGBYKoA;99Rw6lMehHNUc~v^d$;*XEhI>DBabn47 z$W@=)*c;$4&U*MZ)0W>}ln-c->I2iZ9)x;??`zPOl|JHwq4YZmxjnc6 zoZ=d#Y;Mi7OMx^+_TnzG`cqGOqVzc&vOxexAZ2BCEQD=(q}ler><_WuO;|`b~%Ikbcgg&xRUuWC^o$!hmS?28*wGz%3$X;<2046IFOe#`QD}WtN zDNb%1^+It2wA2S@Xd|fWtF(KqXTS%VOZWh7;}7&Db+$)0X%4EFZKgFZMP2G9p@T|Y zE%fn^AE|6TU?2UY;z-C1ULoZNy?5irYNzC*d`st>_#|~O literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp b/static/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp new file mode 100644 index 0000000000000000000000000000000000000000..39c2097cbc1ae2cba8f5b8aa568d71344d5715ef GIT binary patch literal 1408 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 e|5i%Gh`xze6<$w7!YB#yVJQF?=Bh&g00030)t literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp b/static/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp new file mode 100644 index 0000000000000000000000000000000000000000..2f1136c6b617906dd4b71967e047b4a7a752fba1 GIT binary patch literal 2966 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 MaM_avTA%;`0Jg=^vH$=8 literal 0 HcmV?d00001 diff --git a/static/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp b/static/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp new file mode 100644 index 0000000000000000000000000000000000000000..3359779402f1bf0e8d04a4b164850614d51867aa GIT binary patch literal 3952 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 K1H2980000XSF0ZY literal 0 HcmV?d00001 diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 88bd2ed0d5..396d47dc89 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -1,10 +1,22 @@ const { test, expect } = require("./artifact-test"); const AxeBuilder = require("@axe-core/playwright").default; -for (const route of ["/docs/", "/cn/docs/", "/community/", "/cn/community/"]) { +for (const route of [ + "/docs/", + "/cn/docs/", + "/community/", + "/cn/community/", + "/docs/download/download/", + "/cn/docs/download/download/", +]) { test(`axe WCAG 2.2 AA guard ${route}`, async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto(route); + const response = await page.goto(route); + expect(response && response.ok()).toBeTruthy(); + if (route.includes("/docs/download/")) { + await expect(page.locator(".hg-asf-release").first()).toBeVisible(); + await expect(page.locator(".hg-asf-release").first()).toContainText("1.7.0"); + } await page.addStyleTag({ content: "*,*::before,*::after{animation:none!important;transition:none!important}" }); @@ -22,3 +34,17 @@ for (const route of ["/docs/", "/cn/docs/", "/community/", "/cn/community/"]) { expect(blocking).toEqual([]); }); } + +for (const locale of ["en", "cn"]) { + test(`download table scrolls with the keyboard on mobile ${locale}`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${locale === "cn" ? "/cn" : ""}/docs/download/download/`); + const region = page.locator(".hg-asf-release .td-asset-list__table-wrap").first(); + await region.scrollIntoViewIfNeeded(); + await region.focus(); + await expect(region).toBeFocused(); + await region.press("ArrowRight"); + await expect.poll(() => region.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + }); +} diff --git a/tests/e2e/ai.spec.js b/tests/e2e/ai.spec.js index 56209630f5..ac25ac29d1 100644 --- a/tests/e2e/ai.spec.js +++ b/tests/e2e/ai.spec.js @@ -48,6 +48,9 @@ for (const [locale, route, source, language] of [ await expect(tail).toHaveCount(0); await input.fill("server auth"); await tail.locator("[data-hg-ask-ai]").click(); + await expect(page.locator("[data-hg-ai-consent]")).toBeVisible(); + expect(requests).toEqual([]); + await page.locator("[data-hg-ai-consent] [data-hg-ai-continue]").click(); await expect.poll(() => requests.length).toBe(1); await expect.poll(() => page.evaluate(() => window.__kapaCalls || [])).toContainEqual([ "setSourceGroupIDs", [source] @@ -68,6 +71,31 @@ for (const [locale, route, source, language] of [ }); } +test("AI consent cancel and Escape keep native search local", 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 + "/docs/"); + const launcher = page.locator(".hg-ask-ai-launcher"); + await launcher.click(); + const consent = page.locator("[data-hg-ai-consent]"); + await expect(consent).toBeVisible(); + await consent.locator("[data-hg-ai-cancel]").click(); + await expect(consent).toBeHidden(); + await expect(launcher).toBeFocused(); + expect(requests).toEqual([]); + await launcher.click(); + await expect(consent).toBeVisible(); + await consent.press("Escape"); + await expect(consent).toBeHidden(); + await expect(launcher).toBeFocused(); + expect(requests).toEqual([]); + await page.locator("[data-td-shell-search-open]").first().click(); + await expect(page.locator(".td-shell-search__input")).toBeVisible(); +}); + 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) => { @@ -78,6 +106,9 @@ test("AI 500 remains non-blocking and retry issues one fresh request", async ({ await page.goto(AI_ORIGIN + "/docs/"); const launcher = page.locator(".hg-ask-ai-launcher"); await launcher.dblclick(); + await expect(page.locator("[data-hg-ai-consent]")).toBeVisible(); + expect(attempts).toBe(0); + await page.locator("[data-hg-ai-consent] [data-hg-ai-continue]").click(); await expect.poll(() => attempts).toBe(1); await expect(launcher).toHaveAttribute("data-hg-ai-state", "error"); await expect(launcher).toHaveAttribute("title", /unavailable/i); @@ -107,6 +138,9 @@ test("AI pending timeout discards stale state and retry waits for a fresh bundle await page.goto(AI_ORIGIN + "/docs/"); const launcher = page.locator(".hg-ask-ai-launcher"); await launcher.click(); + await expect(page.locator("[data-hg-ai-consent]")).toBeVisible(); + expect(attempts).toBe(0); + await page.locator("[data-hg-ai-consent] [data-hg-ai-continue]").click(); await expect.poll(() => attempts).toBe(1); await expect(launcher).toHaveAttribute("data-hg-ai-state", "error", { timeout: 7_000 diff --git a/tests/e2e/artifact-test.js b/tests/e2e/artifact-test.js index 564a26f860..1b874a9885 100644 --- a/tests/e2e/artifact-test.js +++ b/tests/e2e/artifact-test.js @@ -15,16 +15,22 @@ const test = base.test.extend({ 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": "*" - } - }); + try { + const response = await route.fetch({ url: local.href }); + await route.fulfill({ + response, + headers: { + ...response.headers(), + "access-control-allow-origin": "*" + } + }); + } catch (error) { + if (String(error).includes("Test ended")) return; + throw error; + } }); await use(page); + await page.unrouteAll({ behavior: "ignoreErrors" }); } }); diff --git a/tests/e2e/platform.spec.js b/tests/e2e/platform.spec.js index 4507465708..e7945d6034 100644 --- a/tests/e2e/platform.spec.js +++ b/tests/e2e/platform.spec.js @@ -4,7 +4,7 @@ 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}`; + const key = `oink.sidebar.v2.latest.${locale}`; await expect.poll(() => page.evaluate((name) => localStorage.getItem(name), key)) .not.toBeNull(); const toggle = page @@ -25,6 +25,22 @@ for (const locale of ["en", "cn"]) { await expect(page.locator("#td-shell-sidebar")).toHaveJSProperty("inert", true); const restore = page.locator(".hg-sidebar-restore"); await expect(restore).toBeVisible(); + const edge = page.locator(".hg-sidebar-edge"); + const panel = page.locator(".td-shell-sidebar__panel"); + await page.waitForTimeout(200); + await edge.dispatchEvent("pointerenter", { pointerType: "mouse" }); + await expect(page.locator("#td-shell-sidebar")).toHaveClass( + /td-shell-sidebar--overlay/ + ); + const previewBox = await page.locator(".td-shell-sidebar__panel").boundingBox(); + expect(previewBox.x).toBeLessThanOrEqual(1); + expect(previewBox.y).toBeLessThanOrEqual(1); + await panel.dispatchEvent("pointerenter", { pointerType: "mouse" }); + await panel.dispatchEvent("pointerleave", { pointerType: "mouse" }); + await expect.poll( + () => page.locator("#td-shell-sidebar").getAttribute("class"), + { timeout: 1500 } + ).not.toContain("td-shell-sidebar--overlay"); await restore.click(); await expect(page.locator("#td-shell-sidebar")).not.toHaveAttribute( "aria-hidden", "true" @@ -44,6 +60,32 @@ for (const locale of ["en", "cn"]) { }); } +for (const locale of ["en", "cn"]) { + const prefix = locale === "cn" ? "/cn" : ""; + test(`latest ${locale} docs home opens start and components by default`, async ({ page }) => { + const key = `oink.sidebar.v2.latest.${locale}`; + await page.goto(`${prefix}/docs/`); + await page.evaluate((name) => localStorage.removeItem(name), key); + await page.reload(); + const start = page.locator( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navstart-children"]', + ); + const components = page.locator( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navcomponents-children"]', + ); + const develop = page.locator( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navdevelop-children"]', + ); + await expect(start).toHaveAttribute("aria-expanded", "true"); + await expect(components).toHaveAttribute("aria-expanded", "true"); + await expect(develop).toHaveAttribute("aria-expanded", "false"); + await start.click(); + await page.reload(); + await expect(page.locator(`[aria-controls="${await start.getAttribute("aria-controls")}"]`)) + .toHaveAttribute("aria-expanded", "false"); + }); +} + test("disabled AI emits no UI or Kapa request", async ({ page }) => { const kapaRequests = []; page.on("request", (request) => { @@ -104,7 +146,7 @@ test("Community grid and HTML/Print/Markdown profiles stay in parity", async ({ (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]]) { + for (const [width, columns] of [[1440, 4], [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(); @@ -117,6 +159,18 @@ test("Community grid and HTML/Print/Markdown profiles stay in parity", async ({ await page.reload(); await expect(page.locator(".hg-community-member__link").first()).toBeVisible(); await expect(page.locator(".hg-community-member__initials").first()).toBeAttached(); + await expect(page.locator(".hg-community-member__role-label")).toHaveCount(0); + expect(await page.locator(".hg-community-member__surface:not(.hg-community-member__link)").count()).toBeGreaterThan(0); + expect(await page.locator(".hg-community-member__surface:not(.hg-community-member__link) a").count()).toBe(0); + const publicNames = await page + .locator("#project-members .hg-community-member__identity") + .allTextContents(); + expect(publicNames).toContain("coderzc"); + expect(publicNames).toContain("Jacky Yang"); + expect(publicNames).toContain("Jermy Li"); + await expect(page.getByRole("link", { name: "coderzc on GitHub", exact: true })).toBeVisible(); + await page.goto("/cn/community/"); + await expect(page.getByRole("link", { name: "coderzc 的 GitHub 主页", exact: true })).toBeVisible(); const htmlProfiles = await page .locator("#project-members .hg-community-member__link") diff --git a/tests/e2e/versioning.spec.js b/tests/e2e/versioning.spec.js index d9a1dd751d..fc5b5707ad 100644 --- a/tests/e2e/versioning.spec.js +++ b/tests/e2e/versioning.spec.js @@ -166,6 +166,7 @@ test("historical selectors preserve the readme route across desktop, mobile, and const mobile = page.locator( "#td-shell-sidebar a[data-hg-version-id='1.3']" ); + await page.locator("#td-shell-sidebar .hg-version-overflow summary").click(); await mobile.click(); await expect(page).toHaveURL((url) => url.pathname === "/versions/1.3/cn/docs/introduction/readme/" && @@ -230,6 +231,7 @@ test("introduction aliases resolve bidirectionally without merging canonical pag 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 .hg-version-overflow summary").click(); await page .locator("#td-shell-sidebar a[data-hg-version-id='1.3']") .click(); diff --git a/tests/e2e/workflow-contract.test.cjs b/tests/e2e/workflow-contract.test.cjs index 236648bc42..9e6cbec0d2 100644 --- a/tests/e2e/workflow-contract.test.cjs +++ b/tests/e2e/workflow-contract.test.cjs @@ -37,3 +37,23 @@ test("only publish receives write permission", () => { ); assert.doesNotMatch(workflow, /write-all/); }); + +test("build consumers check out the immutable prepared source SHA", () => { + assert.match(workflow, /echo "source_sha=\$latest_sha"/); + const refs = [...workflow.matchAll(/^\s+ref: \$\{\{([^}]+)\}\}/gm)].map((match) => match[1]); + assert.equal(refs.length, 5); + assert.doesNotMatch(refs[0], /candidate_branch/); + assert.equal(refs.slice(1).filter((ref) => ref.includes("needs.prepare.outputs.source_sha")).length, 4); + assert.doesNotMatch(workflow, /test \"\$GITHUB_REF\" = \"refs\/heads\/\$candidate\"/); + assert.match(workflow, /test \"\$GITHUB_REF\" = \"refs\/heads\/master\"/); +}); + +test("dependency artifacts keep stable names across selective reruns", () => { + assert.match(workflow, /name: resolved-versions-\$\{\{ github\.run_id \}\}/); + assert.match(workflow, /name: \$\{\{ needs\.prepare\.outputs\.artifact_prefix \}\}-\$\{\{ matrix\.version\.id \}\}-\$\{\{ github\.run_id \}\}/); + assert.match(workflow, /pattern: \$\{\{ needs\.prepare\.outputs\.artifact_prefix \}\}-\*-\$\{\{ github\.run_id \}\}/); + assert.match(workflow, /--artifact-suffix="-\$\{GITHUB_RUN_ID\}"/); + assert.match(workflow, /name: hugegraph-site-\$\{\{ needs\.prepare\.outputs\.artifact_prefix \}\}-\$\{\{ github\.run_id \}\}/); + assert.doesNotMatch(workflow, /name: (?:resolved-versions|hugegraph-site-[^\n]+)-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); + assert.equal((workflow.match(/\n\s+overwrite: true/g) ?? []).length, 3); +}); diff --git a/tests/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs index c24e09e36e..997e1a8ab5 100644 --- a/tests/ui-ai/kapa-adapter.test.cjs +++ b/tests/ui-ai/kapa-adapter.test.cjs @@ -21,10 +21,25 @@ function harness() { textContent: '', classList: { toggle() {} }, }; + const consentListeners = new Map(); + const continueButton = { addEventListener(name, callback) { consentListeners.set(`continue:${name}`, callback); } }; + const cancelButton = { addEventListener(name, callback) { consentListeners.set(`cancel:${name}`, callback); } }; + const consent = { + open: false, + showModal() { this.open = true; }, + close() { this.open = false; }, + addEventListener(name, callback) { consentListeners.set(`dialog:${name}`, callback); }, + querySelector(selector) { + if (selector === '[data-hg-ai-continue]') return continueButton; + if (selector === '[data-hg-ai-cancel]') return cancelButton; + return null; + }, + }; const documentObject = { activeElement: trigger, querySelector(selector) { if (selector === '[data-hg-ai-status]') return status; + if (selector === '[data-hg-ai-consent]') return consent; if (selector === 'script[data-hg-kapa-widget]') { return scripts.find((script) => !script.removed) || null; } @@ -85,6 +100,10 @@ function harness() { documentObject, fireRender(index = renderCallbacks.length - 1) { renderCallbacks[index](); }, fireTimeout() { Array.from(timers.values()).forEach((callback) => callback()); }, + continueConsent() { consentListeners.get('continue:click')(); }, + cancelConsent() { consentListeners.get('cancel:click')(); }, + escapeConsent() { consentListeners.get('dialog:keydown')({ key: 'Escape', preventDefault() {}, stopPropagation() {} }); }, + nativeCancel() { consentListeners.get('dialog:cancel')({ preventDefault() {} }); }, installBundle() { const queued = windowObject.Kapa && Array.isArray(windowObject.Kapa.q) @@ -138,6 +157,7 @@ test('sends only the trimmed query after explicit activation and render', () => assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose']); controller.activate(' how to start? ', true, h.trigger); + h.continueConsent(); assert.equal(controller.getState(), 'loading'); assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose', 'render']); @@ -163,6 +183,7 @@ test('ignores duplicate activation and never opens after a late render', () => { h.config, ); controller.activate('first', true, h.trigger); + h.continueConsent(); controller.activate('second', true, h.trigger); assert.equal( h.calls.filter(([name]) => name === 'render').length, @@ -187,6 +208,7 @@ test('launcher opens a blank session without auto-submit', () => { h.config, ); controller.activate('', false, h.trigger); + h.continueConsent(); h.scripts[0].fire('load'); h.fireRender(); assert.deepEqual(h.calls.at(-1), [ @@ -195,6 +217,19 @@ test('launcher opens a blank session without auto-submit', () => { ]); }); +test('cancel, Escape, and native cancel keep Kapa unloaded and restore focus', () => { + for (const close of ['cancelConsent', 'escapeConsent', 'nativeCancel']) { + const h = harness(); + const controller = adapter.createController(h.windowObject, h.documentObject, h.config); + controller.activate('private question', true, h.trigger); + assert.equal(controller.getState(), 'consent'); + h[close](); + assert.equal(controller.getState(), 'idle'); + assert.equal(h.scripts.length, 0); + assert.equal(h.trigger.focused, true); + } +}); + test('a pending timeout retries with a fresh script and ignores the late attempt', () => { const h = harness(); const controller = adapter.createController( @@ -203,6 +238,7 @@ test('a pending timeout retries with a fresh script and ignores the late attempt h.config, ); controller.activate('first', true, h.trigger); + h.continueConsent(); assert.equal(h.scripts.length, 1); const staleRender = h.renderCallbacks[0]; @@ -232,3 +268,141 @@ test('a pending timeout retries with a fresh script and ignores the late attempt { mode: 'ai', query: 'second', submit: true }, ]); }); + +test('init succeeds without search shell and binds standalone triggers', () => { + const h = harness(); + const configNode = { + textContent: JSON.stringify({ + websiteId: 'test-id', + sourceGroupId: 'test-group', + locale: 'en', + themeColor: '#532fc9', + historical: false, + labels: { ask: 'Ask AI' }, + }), + }; + const doc = { + ...h.documentObject, + getElementById(id) { + if (id === 'hg-ai-config') return configNode; + if (id === 'td-shell-search') return null; + return null; + }, + }; + h.trigger.addEventListener = (name, cb) => {}; + const controller = adapter.init(h.windowObject, doc); + assert.ok(controller); + assert.equal(h.trigger.dataset.hgAiBound, ''); +}); + +test('init wires search shell Enter handler and updates noResults text', () => { + global.MutationObserver = class { + observe() {} + disconnect() {} + }; + const h = harness(); + h.trigger.addEventListener = (name, cb) => {}; + const configNode = { + textContent: JSON.stringify({ + websiteId: 'test-id', + sourceGroupId: 'test-group', + locale: 'en', + themeColor: '#532fc9', + historical: false, + labels: { ask: 'Ask AI', noResults: 'No documentation results found' }, + }), + }; + const emptyNode = { + className: 'td-shell-search__empty', + textContent: 'old empty', + }; + const tailBtn = { + dataset: { hgAskAi: '' }, + addEventListener() {}, + }; + const tailGroup = { + dataset: { hgAiSearchTail: '' }, + querySelector(sel) { + if (sel === '[data-hg-ask-ai]') return tailBtn; + return null; + }, + remove() {}, + }; + const list = { + className: 'td-shell-search__list', + querySelector(sel) { + if (sel === '.td-shell-search__empty') return emptyNode; + if (sel === '.td-shell-search__item:not(.hg-ai-search-tail__button)') return null; + if (sel === '[data-hg-ai-search-tail] [data-hg-ask-ai]') return tailBtn; + if (sel === '[data-hg-ai-search-tail]') return tailGroup; + return null; + }, + querySelectorAll(sel) { + if (sel === '.td-shell-search__empty') return [emptyNode]; + if (sel === '.td-shell-search__group-label') return []; + return []; + }, + appendChild() {}, + }; + const inputListeners = new Map(); + const input = { + value: 'graph query', + addEventListener(event, handler) { + inputListeners.set(event, handler); + }, + }; + const root = { + dataset: {}, + hidden: false, + querySelector(sel) { + if (sel === '.td-shell-search__input') return input; + if (sel === '.td-shell-search__list') return list; + return null; + }, + }; + const doc = { + ...h.documentObject, + getElementById(id) { + if (id === 'hg-ai-config') return configNode; + if (id === 'td-shell-search') return root; + return null; + }, + createElement(name) { + if (name === 'script') return h.documentObject.createElement('script'); + return { + className: '', + dataset: {}, + setAttribute() {}, + appendChild() {}, + addEventListener() {}, + }; + }, + }; + + const controller = adapter.init(h.windowObject, doc); + assert.ok(controller); + + // Assert empty node text was updated + assert.equal(emptyNode.textContent, 'No documentation results found'); + + // Trigger Enter on input + const keydown = inputListeners.get('keydown'); + assert.ok(keydown); + let prevented = false; + keydown({ + key: 'Enter', + preventDefault() { prevented = true; }, + stopImmediatePropagation() {}, + }); + assert.equal(prevented, true); + h.continueConsent(); + h.installBundle(); + h.scripts[0].fire('load'); + h.fireRender(); + assert.deepEqual(h.calls.at(-1), [ + 'open', + { mode: 'ai', query: 'graph query', submit: true }, + ]); +}); + + diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs index c67f12fe7b..d4d3c30cda 100644 --- a/tests/ui-ai/ui-contract.test.cjs +++ b/tests/ui-ai/ui-contract.test.cjs @@ -87,7 +87,7 @@ test('image zoom is limited to docs and blog', () => { test('shell persistence uses the version and locale scoped key', () => { const source = read('assets/js/hugegraph-shell.js'); - assert.match(source, /oink\.sidebar\.v1\./); + assert.match(source, /oink\.sidebar\.v2\./); assert.match(source, /config\.version/); assert.match(source, /config\.locale/); assert.match(source, /sidebar\.inert = isolated/); @@ -96,8 +96,8 @@ test('shell persistence uses the version and locale scoped key', () => { test('all three version selector surfaces expose one stable route contract', () => { const navbar = read('layouts/_partials/navbar.html'); const sidebar = read('layouts/_partials/shell/sidebar-panel.html'); - assert.equal((navbar.match(/partial "version-link\.html"/g) || []).length, 2); - assert.equal((sidebar.match(/partial "version-link\.html"/g) || []).length, 1); + assert.equal((navbar.match(/partial "version-menu-links\.html"/g) || []).length, 2); + assert.equal((sidebar.match(/partial "version-menu-links\.html"/g) || []).length, 1); assert.match( read('layouts/_partials/version-link.html'), /data-hg-version-id=/, From a5a9861ea15f2183347149d0943a758493f1f5b1 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 23 Sep 2026 03:57:04 +0800 Subject: [PATCH 2/3] fix(build): resolve OINK from candidate lock files (#497) * fix(ci): honor the candidate OINK version - read the OINK requirement from candidate go.mod - retain module integrity and replacement checks - allow staging upgrades before production merges * fix(build): resolve OINK from candidate lock files - share module identity and integrity validation - remove the migration tool version and checksum literals - accept valid blank lines in go.sum - cover supported pins and dependency boundary failures --- .github/workflows/hugo.yml | 6 +-- scripts/oink_module.py | 65 +++++++++++++++++++++++++++++ scripts/test_oink_module.py | 81 +++++++++++++++++++++++++++++++++++++ scripts/versioning.py | 28 +++---------- 4 files changed, 152 insertions(+), 28 deletions(-) create mode 100644 scripts/oink_module.py create mode 100644 scripts/test_oink_module.py diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index f9c751bcc6..adfc300145 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -231,11 +231,7 @@ jobs: restore-keys: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}- - name: Verify pinned OINK module run: | - go mod verify - 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)" + python3 scripts/oink_module.py - name: Build isolated version artifact env: OINK_PYTHON: python3 diff --git a/scripts/oink_module.py b/scripts/oink_module.py new file mode 100644 index 0000000000..6bca61db45 --- /dev/null +++ b/scripts/oink_module.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0. + +"""Resolve the sole theme dependency from the checked-in Go lock files.""" +import json +import os +import re +import subprocess +from pathlib import Path + +MODULE = "github.com/pgsty/oink" +SITE_MODULE = "github.com/apache/hugegraph-doc" +VERSION = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?") + + +def command(args, root): + return subprocess.check_output([os.environ.get("GO_BIN", "go"), *args], cwd=root, text=True) + + +def locked_version(root): + data = json.loads(command(["mod", "edit", "-json"], root)) + requirements = data.get("Require") or [] + if (data.get("Module", {}).get("Path") != SITE_MODULE + or data.get("Replace") or data.get("Exclude") + or len(requirements) != 1 or requirements[0]["Path"] != MODULE): + raise ValueError("expected the HugeGraph module with only OINK and no replace/exclude directives") + version = requirements[0]["Version"] + if not VERSION.fullmatch(version): + raise ValueError("OINK must be pinned to an explicit release version") + return version + + +def download_locked(root): + root = Path(root) + version = locked_version(root) + sums = {} + for line in (root / "go.sum").read_text().splitlines(): + if not line.strip(): + continue + path, revision, digest = line.split() + sums[(path, revision)] = digest + expected = [sums.get((MODULE, version)), sums.get((MODULE, version + "/go.mod"))] + if not all(expected): + raise ValueError("OINK module and go.mod checksums must already exist in go.sum") + module = json.loads(command(["mod", "download", "-json", MODULE + "@" + version], root)) + if (module.get("Path") != MODULE or module.get("Version") != version + or module.get("Sum") != expected[0] or module.get("GoModSum") != expected[1] + or module.get("Error")): + raise ValueError("downloaded OINK identity or checksum differs from lock files") + resolved = json.loads(command(["list", "-m", "-json", MODULE], root)) + if resolved.get("Replace") or resolved.get("Version") != version or resolved.get("Path") != MODULE: + raise ValueError("resolved OINK module differs from lock files") + graph = command(["list", "-m", "all"], root).splitlines() + if graph != [SITE_MODULE, MODULE + " " + version]: + raise ValueError("module graph must contain only this site and the pinned OINK module") + command(["mod", "verify"], root) + return module + + +if __name__ == "__main__": + print(json.dumps(download_locked(Path(__file__).resolve().parents[1]), indent=2)) diff --git a/scripts/test_oink_module.py b/scripts/test_oink_module.py new file mode 100644 index 0000000000..897d4691ac --- /dev/null +++ b/scripts/test_oink_module.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0. + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import oink_module + + +class ModuleLockTest(unittest.TestCase): + def config(self, version="v1.0.0", **changes): + return dict(Module={"Path": oink_module.SITE_MODULE}, + Require=[{"Path": oink_module.MODULE, "Version": version}], **changes) + + def download(self, version="v1.0.0", config=None, metadata=None, resolved=None, graph=None, sums=True): + module = dict(Path=oink_module.MODULE, Version=version, Sum="h1:archive", GoModSum="h1:mod") + resolution = dict(Path=oink_module.MODULE, Version=version) + module.update(metadata or {}) + resolution.update(resolved or {}) + replies = [json.dumps(config or self.config(version)), json.dumps(module), + json.dumps(resolution), graph or f"{oink_module.SITE_MODULE}\n{oink_module.MODULE} {version}\n", "all modules verified"] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "go.sum").write_text( + f"\n{oink_module.MODULE} {version} h1:archive\n\n{oink_module.MODULE} {version}/go.mod h1:mod\n" if sums else "") + with patch.object(oink_module, "command", side_effect=replies) as command: + result = oink_module.download_locked(root) + self.assertEqual(command.call_args.args[0], ["mod", "verify"]) + return result + + def test_accepts_each_locked_release(self): + for version in ["v1.0.0", "v1.1.0"]: + with self.subTest(version=version): + self.assertEqual(self.download(version)["Version"], version) + + def test_rejects_changed_dependency_boundary(self): + configurations = [self.config(Replace=[{"New": {"Path": "local"}}]), + self.config(Exclude=[{"Path": oink_module.MODULE, "Version": "v1.0.0"}])] + wrong_site = self.config() + wrong_site["Module"]["Path"] = "example.com/site" + configurations.append(wrong_site) + extra = self.config() + extra["Require"].append({"Path": "example.com/extra", "Version": "v1.0.0"}) + configurations.append(extra) + for config in configurations: + with self.subTest(config=config), self.assertRaises(ValueError): + self.download(config=config) + + def test_missing_checksums_stop_before_download(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "go.sum").write_text("") + with patch.object(oink_module, "command", return_value=json.dumps(self.config())) as command: + with self.assertRaises(ValueError): + oink_module.download_locked(root) + self.assertEqual(command.call_count, 1) + + def test_rejects_download_identity_or_checksum_mismatch(self): + for metadata in [{"Sum": "h1:wrong"}, {"GoModSum": "h1:wrong"}, + {"Path": "example.com/theme"}, {"Version": "v9.0.0"}]: + with self.subTest(metadata=metadata), self.assertRaises(ValueError): + self.download(metadata=metadata) + + def test_rejects_resolved_override_and_extra_dependency(self): + for resolved in [{"Replace": {"Path": "local"}}, {"Version": "v9.0.0"}, + {"Path": "example.com/theme"}]: + with self.subTest(resolved=resolved), self.assertRaises(ValueError): + self.download(resolved=resolved) + with self.assertRaises(ValueError): + self.download(graph=f"{oink_module.SITE_MODULE}\n{oink_module.MODULE} v1.0.0\nexample.com/extra v1.0.0\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/versioning.py b/scripts/versioning.py index 84d9a2d5fc..e9b48cc24d 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -34,6 +34,8 @@ import xml.etree.ElementTree as ET from typing import NoReturn +from oink_module import download_locked + ROOT = pathlib.Path(__file__).resolve().parents[1] URL_CONTRACT = ROOT / "dist/url-contract.json" @@ -4068,30 +4070,10 @@ def build(args: argparse.Namespace) -> None: json.dumps(override, ensure_ascii=False), encoding="utf-8" ) hugo = os.environ.get("HUGO_BIN", "hugo") - go = os.environ.get("GO_BIN", "go") - go_executable = shutil.which(go) + go_executable = shutil.which(os.environ.get("GO_BIN", "go")) if go_executable is None: - fail(f"Go executable is unavailable: {go}") - module_result = subprocess.run( - [ - go_executable, - "mod", - "download", - "-json", - "github.com/pgsty/oink@v1.0.0", - ], - cwd=assembly, - check=True, - stdout=subprocess.PIPE, - text=True, - ) - module = json.loads(module_result.stdout) - if ( - module.get("Path") != "github.com/pgsty/oink" - or module.get("Version") != "v1.0.0" - or module.get("Sum") != "h1:E+WHFP9zSRT+5RKoIkWNp+ASRGS1BKG+rDEi9by/BjE=" - ): - fail(f"unexpected OINK module metadata: {module!r}") + fail("Go executable is unavailable") + module = download_locked(assembly) migration_script = pathlib.Path(module["Dir"]) / "bin/migrations/oink06.py" if not migration_script.is_file(): fail(f"pinned OINK migration tool is absent: {migration_script}") From cd6e442fe22c034e70e5b4e85f77eb68cd0ebec5 Mon Sep 17 00:00:00 2001 From: imbajin Date: Fri, 25 Sep 2026 17:43:32 +0800 Subject: [PATCH 3/3] feat: upgrade OINK to 1.1 with maintainable site extensions (#496) Upgrade OINK 1.0 to 1.1 and keep HugeGraph customizations on the theme's sidebar and search-tail APIs. The theme owns rendering and keyboard interactions; HugeGraph retains version/locale preferences, consent-gated Ask AI and cancellation/focus handling. --- .github/workflows/hugo.yml | 51 ++-- .htaccess | 5 + AGENTS.md | 148 +++-------- NOTICE | 2 +- README.md | 12 +- assets/js/hugegraph-shell.js | 292 ++++----------------- assets/js/kapa-adapter.js | 211 +++++++-------- assets/scss/_styles_project.scss | 60 ----- contribution.md | 35 ++- go.mod | 3 +- go.sum | 5 +- hugo.yaml | 10 +- i18n/en.yaml | 2 - i18n/zh-CN.yaml | 238 +---------------- layouts/_partials/hooks/body-end.html | 2 - layouts/_partials/navbar-item.html | 2 +- layouts/_partials/navbar.html | 7 - layouts/_partials/print/page-content.html | 26 +- layouts/_partials/shell/config.html | 2 +- layouts/_partials/shell/sidebar-panel.html | 2 +- layouts/_partials/shell/toc.html | 2 +- scripts/kapa-rollout.md | 121 +++++++++ scripts/oink-overrides.json | 97 +++++++ scripts/oink-upgrade.md | 113 ++++++++ scripts/oink_module.py | 49 ++-- scripts/test_download_data.py | 6 +- scripts/test_oink_module.py | 67 ++++- scripts/test_oink_upgrade.py | 101 +++++++ scripts/test_update_oink.py | 99 +++++++ scripts/test_versioning.py | 37 ++- scripts/update-oink.sh | 9 + scripts/update_oink.py | 165 ++++++++++++ scripts/versioning.py | 19 +- tests/e2e/ai-disabled.yaml | 3 + tests/e2e/ai.spec.js | 69 ++++- tests/e2e/package.json | 2 +- tests/e2e/platform.spec.js | 61 +++-- tests/e2e/playwright.config.js | 11 + tests/e2e/search-ranking.spec.js | 10 +- tests/e2e/theme-upgrade.spec.js | 95 +++++++ tests/e2e/visual.spec.js | 4 +- tests/e2e/workflow-contract.test.cjs | 52 +++- tests/ui-ai/kapa-adapter.test.cjs | 258 +++++++++++------- tests/ui-ai/sidebar-persistence.test.cjs | 130 +++++++++ tests/ui-ai/ui-contract.test.cjs | 8 +- 45 files changed, 1710 insertions(+), 993 deletions(-) create mode 100644 scripts/kapa-rollout.md create mode 100644 scripts/oink-overrides.json create mode 100644 scripts/oink-upgrade.md create mode 100644 scripts/test_oink_upgrade.py create mode 100644 scripts/test_update_oink.py create mode 100755 scripts/update-oink.sh create mode 100644 scripts/update_oink.py create mode 100644 tests/e2e/ai-disabled.yaml create mode 100644 tests/e2e/theme-upgrade.spec.js create mode 100644 tests/ui-ai/sidebar-persistence.test.cjs diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index adfc300145..50cf9928b0 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -28,6 +28,7 @@ on: concurrency: 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') }} + # A newer commit supersedes previous work for the same PR or publish target. cancel-in-progress: true env: @@ -231,7 +232,10 @@ jobs: restore-keys: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}- - name: Verify pinned OINK module run: | - python3 scripts/oink_module.py + test "$(go list -m -f '{{ .Path }}')" = "github.com/apache/hugegraph-doc" + test "$(go list -m all | wc -l)" -eq 2 + test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}{{ end }}' github.com/pgsty/oink)" + python3 scripts/update_oink.py --check-baseline - name: Build isolated version artifact env: OINK_PYTHON: python3 @@ -256,11 +260,14 @@ jobs: if-no-files-found: error overwrite: true + # Assembly and browser checks are serial; reuse one runner and its local output. aggregate: needs: [prepare, build] runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 35 permissions: { contents: read } + env: + HUGO_CACHEDIR: /tmp/hugo-cache-${{ github.run_id }}-${{ github.run_attempt }}-aggregate steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -309,18 +316,6 @@ jobs: if-no-files-found: error overwrite: true - 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: ${{ needs.prepare.outputs.source_sha }} - persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "24" @@ -335,10 +330,6 @@ jobs: 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 }} - path: public-site - name: Install Chromium test workspace working-directory: tests/e2e run: | @@ -355,15 +346,27 @@ jobs: --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: Build AI-disabled rollback fixture + run: | + python3 scripts/versioning.py config \ + --version latest \ + --site-origin http://127.0.0.1:4175/ \ + --historical-origin "$PRODUCTION_ORIGIN" \ + --output "${RUNNER_TEMP}/ai-disabled-version-config.json" + hugo \ + --config "hugo.yaml,${RUNNER_TEMP}/ai-disabled-version-config.json,tests/e2e/ai-disabled.yaml" \ + --destination "${RUNNER_TEMP}/ai-disabled-site" \ + --cleanDestinationDir --gc --minify --environment production - name: Run blocking Chromium contracts working-directory: tests/e2e env: - SITE_ROOT: ${{ github.workspace }}/public-site + SITE_ROOT: ${{ runner.temp }}/public-site AI_SITE_ROOT: ${{ runner.temp }}/ai-site + AI_DISABLED_SITE_ROOT: ${{ runner.temp }}/ai-disabled-site EXPECTED_VERSIONS: ${{ needs.prepare.outputs.selection }} run: npm run test:ci - name: Upload E2E report - if: always() + if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: playwright-report-${{ github.run_id }}-${{ github.run_attempt }} @@ -402,7 +405,7 @@ jobs: npx playwright install --with-deps chromium npm run test:visual - name: Upload advisory visual evidence - if: always() + if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: visual-evidence-${{ github.run_id }}-${{ github.run_attempt }} @@ -412,8 +415,8 @@ jobs: # Required check name in .asf.yaml. It gates artifacts without write access. deploy: - if: always() - needs: [prepare, build, aggregate, e2e] + if: ${{ !cancelled() }} + needs: [prepare, build, aggregate] runs-on: ubuntu-latest timeout-minutes: 5 permissions: { contents: read } @@ -423,12 +426,10 @@ jobs: 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 publish: if: needs.prepare.outputs.publish_branch != '' diff --git a/.htaccess b/.htaccess index 295223cc29..a53d7c803e 100644 --- a/.htaccess +++ b/.htaccess @@ -1,2 +1,7 @@ RedirectMatch 404 "(?i)(?:^|/)\.git(?:/|$)" ErrorDocument 404 /404.html +# Reuse the existing HugeGraph Kapa/hCaptcha integration, PR #459. +# Source: https://github.com/apache/hugegraph-doc/commit/7662b8afb64265ed7412f6d704b8adfa9a836fff +# Preserve ASF default CSP; no Google CAPTCHA or optional metrics domains. +# Existing deployment evidence is not a separate VP Privacy approval record. +SetEnv CSP_PROJECT_DOMAINS "https://widget.kapa.ai https://proxy.kapa.ai https://kapa-widget-proxy-la7dkmplpq-uc.a.run.app https://hcaptcha.com https://*.hcaptcha.com" diff --git a/AGENTS.md b/AGENTS.md index c4a0545150..6e89817082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,110 +1,38 @@ -# AGENTS.md - -This file provides guidance to AI coding assistants (Claude Code, Cursor, GitHub Copilot, etc.) when working with code in this repository. - -## Project Overview - -Apache HugeGraph documentation website built with Hugo static site generator and the Docsy theme. The site is bilingual (Chinese/English) and covers the complete HugeGraph graph database ecosystem. - -## Development Commands - -```bash -# Install dependencies -npm install - -# Start development server (auto-reload enabled) -hugo server - -# Build production site (output to ./public) -hugo --minify - -# Clean build -rm -rf public/ - -# Production build with garbage collection -HUGO_ENV="production" hugo --gc - -# Custom server configuration -hugo server -b http://127.0.0.1 -p 80 --bind=0.0.0.0 -``` - -## Prerequisites - -- **Hugo Extended** v0.95.0 recommended (v0.102.3 in CI) - must be the "extended" version for SASS/SCSS support -- **Node.js** v16+ and npm -- Download Hugo from: https://github.com/gohugoio/hugo/releases - -## Architecture - -``` -content/ -├── cn/ # Chinese documentation (default language) -│ ├── docs/ # Main documentation -│ ├── blog/ # Blog posts -│ ├── community/ -│ └── about/ -└── en/ # English documentation (parallel structure) - -themes/docsy/ # Docsy theme (submodule) -layouts/ # Custom template overrides -assets/ # Processed assets (SCSS, images) -static/ # Static files served directly -config.toml # Main site configuration -``` - -### Content Structure - -Documentation sections in `content/{cn,en}/docs/`: -- `quickstart/` - Getting started guides for HugeGraph components -- `config/` - Configuration documentation -- `clients/` - Client API documentation (Gremlin, RESTful) -- `guides/` - User guides and tutorials -- `performance/` - Benchmarks and optimization -- `language/` - Query language docs -- `contribution-guidelines/` - Contributing guides -- `changelog/` - Release notes -- `download/` - Download instructions - -## Key Configuration Files - -- `config.toml` - Site-wide settings, language config, menu structure, version (currently 0.13) -- `package.json` - Node dependencies for CSS processing (postcss, autoprefixer, mermaid) -- `.editorconfig` - UTF-8, LF line endings, spaces for indentation - -## Working with Content - -When editing documentation: -1. Maintain parallel structure between `content/cn/` and `content/en/` -2. Use Markdown with Hugo front matter (title, weight, description) -3. For bilingual changes, update both Chinese and English versions -4. Include mermaid diagrams where appropriate (mermaid.js is available) - -## Deployment - -- **CI/CD**: GitHub Actions (`.github/workflows/hugo.yml`) -- **Trigger**: Push to `master` branch or pull requests -- **Build**: `npm i && hugo --minify` with Node v16 and Hugo v0.102.3 extended -- **Deploy**: Publishes to `asf-site` branch (GitHub Pages) -- **PR Requirements**: Include screenshots showing before/after changes - -## HugeGraph Ecosystem Context - -This documentation covers: -- **HugeGraph-Server** - Core graph database with REST API -- **HugeGraph-Store** - Distributed storage engine -- **HugeGraph-PD** - Placement Driver for metadata -- **Toolchain** - Client, Loader, Hubble (web UI), Tools -- **HugeGraph-Computer** - Distributed OLAP graph processing -- **HugeGraph-AI** - GNN, LLM/RAG components - -## Troubleshooting - -**"TOCSS: failed to transform scss/main.scss"** -- Install Hugo Extended (not standard Hugo) - -**Theme/module not found** -- Run: `git submodule update --init --recursive` - -**CI build fails but works locally** -- Match Hugo version (v0.102.3) and Node.js (v16) -- Verify npm dependencies are installed +# HugeGraph documentation + +Bilingual Hugo site using OINK as a pinned Go module. English routes live under +`/docs/`, Chinese routes under `/cn/docs/`; historical releases share the current +site shell. + +## Task entry points + +- Preview or build: `scripts/hugo.sh server` / `scripts/hugo.sh build`. +- Content and contribution checks: [contribution.md](contribution.md). +- OINK upgrades, customization boundaries and recovery: + [scripts/oink-upgrade.md](scripts/oink-upgrade.md). +- Version assembly and publishing: `scripts/versioning.py` and + `.github/workflows/hugo.yml`; release selection comes from `versions.json`. +- Browser regression: `tests/e2e/package.json` and its Playwright configuration. + Node dependencies are for tests, not the Hugo site build. + +Read the entry relevant to the task; small content edits do not require the full +upgrade or deployment workflow. Use `go.mod` / `go.sum` and CI for pinned versions. + +## Project constraints + +- Keep English and Chinese documentation aligned when a change applies to both. +- Preserve public routes, historical-version navigation and language switching. +- Keep HugeGraph branding and behavior in site configuration, data, hooks and + public OINK APIs. Do not edit the module cache or vendor a theme fork. +- Record necessary upstream template overrides in the upgrade inventory; when + upstream changes them, reconcile the customization with the new implementation. +- AI resources load only after explicit user consent; disabled AI must make no + third-party AI requests. +- Validate behavior affected by the change. Theme/runtime changes require strict + builds and browser checks; visual/navigation changes also need before/after + screenshots. A rendered site alone does not prove interaction compatibility. +- Independent work may run in parallel. Review the final combined diff independently + for changes affecting runtime behavior or multiple components; local content + edits can use self-review. +- Local builds and disposable tests may run without repeated confirmation. Remote + writes and publishing follow the user's explicit task scope. diff --git a/NOTICE b/NOTICE index 91cb6e33a5..c7bce531b6 100644 --- a/NOTICE +++ b/NOTICE @@ -6,5 +6,5 @@ The Apache Software Foundation (http://www.apache.org/). The initial codebase was donated to the ASF by HugeGraph Authors, copyright 2017-2021. -This product includes OINK v1.0.0 (https://github.com/pgsty/oink), +This product includes OINK (https://github.com/pgsty/oink), Copyright 2026 PGSTY contributors, licensed under the Apache License 2.0. diff --git a/README.md b/README.md index 6b2b71db2c..1e618d088d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ scripts/hugo.sh server Open http://localhost:1313 to preview. -The module graph must resolve `github.com/pgsty/oink@v1.0.0`. For a production-equivalent check, run the strict build command shown below. +The module graph must resolve the OINK version pinned in `go.mod`; verify it with `python3 scripts/oink_module.py`. For a production-equivalent check, run the strict build command shown below. ## Repository Structure @@ -51,7 +51,7 @@ hugegraph-doc/ │ └── en/ # 🇺🇸 English documentation (mirrors cn/ structure) │ ├── data/ # 🧭 Landing-page and footer data -├── i18n/zh-CN.yaml # 🌐 OINK interface strings for the /cn/ locale +├── i18n/zh-CN.yaml # 🌐 HugeGraph labels for the /cn/ locale ├── assets/ # 🖼️ Project brand assets ├── layouts/ # 📐 Hugo template overrides ├── static/ # 📁 Static files @@ -79,6 +79,7 @@ hugegraph-doc/ ### Detailed Guide See [contribution.md](./contribution.md) for the pinned toolchain, strict build, OINK customization, and translation rules. +For theme updates, compatibility review and rollback, use the [OINK upgrade SOP](./scripts/oink-upgrade.md). ## Commands @@ -87,6 +88,7 @@ See [contribution.md](./contribution.md) for the pinned toolchain, strict build, | `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 | +| `scripts/update-oink.sh v1.1.0` | Update the theme and run compatibility checks (see the SOP) | --- @@ -116,7 +118,7 @@ scripts/hugo.sh server 打开 http://localhost:1313 预览网站。 -模块图必须解析为 `github.com/pgsty/oink@v1.0.0`。需要执行与生产一致的检查时,请运行下方的严格构建命令。 +模块图必须解析为 `go.mod` 固定的 OINK 版本,可运行 `python3 scripts/oink_module.py` 校验。需要执行与生产一致的检查时,请运行下方的严格构建命令。 ### 仓库结构 @@ -135,7 +137,7 @@ hugegraph-doc/ │ └── en/ # 🇺🇸 英文文档(与 cn/ 结构一致) │ ├── data/ # 🧭 首页与页尾数据 -├── i18n/zh-CN.yaml # 🌐 /cn/ 语言的 OINK 界面文案 +├── i18n/zh-CN.yaml # 🌐 /cn/ 语言的 HugeGraph 专属文案 ├── assets/ # 🖼️ 项目品牌资源 ├── layouts/ # 📐 Hugo 模板覆盖 ├── static/ # 📁 静态文件 @@ -163,6 +165,7 @@ hugegraph-doc/ #### 详细指南 查看 [contribution.md](./contribution.md) 了解固定工具链、严格构建、OINK 定制和翻译要求。 +主题升级、兼容适配和回退请按 [OINK 升级 SOP](./scripts/oink-upgrade.md) 操作。 ### 常用命令 @@ -171,6 +174,7 @@ hugegraph-doc/ | `scripts/hugo.sh server` | 启动读取版本清单的开发服务器(支持热重载) | | `scripts/hugo.sh build` | 严格构建与生产等价的站点到 `./public/` | | `scripts/hugo.sh server -p 8080` | 在指定端口启动开发服务器 | +| `scripts/update-oink.sh v1.1.0` | 更新主题并执行兼容检查(详见升级 SOP) | --- diff --git a/assets/js/hugegraph-shell.js b/assets/js/hugegraph-shell.js index 09240eb546..2cbf2e7028 100644 --- a/assets/js/hugegraph-shell.js +++ b/assets/js/hugegraph-shell.js @@ -2,8 +2,7 @@ * 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. + * persists authored tree disclosures through OINK's public API and adds an explicit retry control to the existing search error. */ (function (global) { 'use strict'; @@ -32,245 +31,69 @@ } } - 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.v2.' + - String(config.version || 'latest') + - '.' + - String(config.locale || 'en'); - var valid = new Set( - buttons.map(function (button) { + var sidebar = windowObject.OinkSidebar; + if (!sidebar) return; + return sidebar.ready.then(function () { + var buttons = Array.prototype.slice.call(documentObject.querySelectorAll( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls], ' + + '[data-td-shell-aside] [data-td-shell-tree-toggle][aria-controls]', + )); + if (!buttons.length) return; + var storage = safeStorage(windowObject); + var scope = String(config.version || 'latest') + '.' + String(config.locale || 'en'); + var key = 'oink.sidebar.v3.' + scope; + var valid = new Set(buttons.map(function (button) { return button.getAttribute('aria-controls'); - }), - ); - var saved = []; - var hasSavedState = false; - if (storage) { - try { - var stored = storage.getItem(key); - var parsed = JSON.parse(stored || '[]'); - if (Array.isArray(parsed)) { - hasSavedState = stored !== null; - saved = parsed.filter(function (id) { - return typeof id === 'string' && valid.has(id); - }); - } - } catch (_) { - saved = []; - } - } - var remembered = new Set(saved); - var docsRoot = /(?:^|\/)(?:cn\/)?docs\/?$/.test(windowObject.location.pathname); - - buttons.forEach(function (button) { - var item = button.closest('li'); - var activePath = item && item.classList.contains('td-active-path'); - var control = button.getAttribute('aria-controls') || ''; - var defaultOpen = - !hasSavedState && - docsRoot && - /_nav(?:start|components)-children$/.test(control); - setTreeExpanded( - button, - Boolean(activePath || remembered.has(control) || defaultOpen), - 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. */ + })); + var remembered = new Set(); + var hasSavedState = false; + var legacyState = false; + if (storage) { + try { + var stored = storage.getItem(key); + if (stored === null) { + stored = storage.getItem('oink.sidebar.v2.' + scope); + legacyState = stored !== null; } - }, 0); - }); - }); - - // Seed the new persistence schema once so the docs-home defaults survive - // reloads; later clicks replace this set with the user's choices. - if (storage && !hasSavedState) { - try { - var initial = buttons - .filter(function (button) { - var item = button.closest('li'); - return ( - button.getAttribute('aria-expanded') === 'true' && - !(item && item.classList.contains('td-active-path')) - ); - }) - .map(function (button) { - return button.getAttribute('aria-controls'); - }); - storage.setItem(key, JSON.stringify(initial)); - } catch (_) { - /* Ignore storage becoming unavailable after the probe. */ + var parsed = JSON.parse(stored || '[]'); + if (Array.isArray(parsed)) { + hasSavedState = stored !== null; + remembered = new Set(parsed.filter(function (id) { + return typeof id === 'string' && valid.has(id); + })); + } + } catch (_) { /* Keep active-path and docs-home defaults. */ } } - } - } - - function initSidebarIsolation(windowObject, documentObject) { - var html = documentObject.documentElement; - var sidebar = documentObject.getElementById('td-shell-sidebar'); - if (!sidebar) return; - var restore = documentObject.querySelector('.hg-sidebar-restore'); - var desktop = windowObject.matchMedia('(min-width: 768px)'); - - var panel = sidebar.querySelector('.td-shell-sidebar__panel'); - // The native 16px panel edge cannot receive pointers while the sidebar is - // inert. Keep an equivalent pointer-only strip outside the inert subtree; - // the labelled navbar button remains the keyboard/touch equivalent. - var edge = documentObject.createElement('div'); - edge.className = 'hg-sidebar-edge d-print-none'; - edge.setAttribute('aria-hidden', 'true'); - documentObject.body.appendChild(edge); - var closeTimer; - var pointerLockUntil = 0; - function dynamic() { - return desktop.matches && - html.getAttribute('data-td-shell-sidebar') === 'collapsed'; - } - function preview() { - if (!dynamic()) return; - windowObject.clearTimeout(closeTimer); - sidebar.classList.add('td-shell-sidebar--overlay'); - sync(); - } - function closePreview() { - windowObject.clearTimeout(closeTimer); - closeTimer = windowObject.setTimeout(function () { - if (!sidebar.contains(documentObject.activeElement)) { - sidebar.classList.remove('td-shell-sidebar--overlay'); - sync(); - } - }, 350); - } - edge.addEventListener('pointerenter', function (event) { - if (event.pointerType !== 'touch' && Date.now() >= pointerLockUntil) preview(); - }); - edge.addEventListener('pointerleave', closePreview); - if (restore) { - restore.addEventListener('pointerenter', function (event) { - if (event.pointerType !== 'touch' && Date.now() >= pointerLockUntil) preview(); - }); - restore.addEventListener('pointerleave', closePreview); - restore.addEventListener('keydown', function (event) { - if (event.key !== 'ArrowRight' || !dynamic()) return; - event.preventDefault(); - preview(); - var first = sidebar.querySelector('a[href], button'); - if (first) first.focus(); - }); - } - if (panel) { - panel.addEventListener('pointerenter', function () { - windowObject.clearTimeout(closeTimer); + var docsRoot = /(?:^|\/)(?:cn\/)?docs\/?$/.test(windowObject.location.pathname); + buttons.forEach(function (button) { + var id = button.getAttribute('aria-controls'); + var isAside = Boolean(button.closest('[data-td-shell-aside]')); + // v2 stored only the main tree: an absent aside id was not a choice + // to collapse it. v3 records both, including an explicitly empty set. + var defaultOpen = isAside + ? (!hasSavedState || legacyState) && sidebar.getState(id).expanded + : !hasSavedState && docsRoot && /_nav(?:start|components)-children$/.test(id); + // OINK preserves the active path and owns DOM, inert and ARIA state. + sidebar.setExpanded(id, remembered.has(id) || defaultOpen, { source: 'api' }); }); - panel.addEventListener('pointerleave', closePreview); - panel.addEventListener('focusout', closePreview); - panel.addEventListener('keydown', function (event) { - if (event.key !== 'Escape' || !dynamic()) return; - event.preventDefault(); - if (restore) restore.focus(); - sidebar.classList.remove('td-shell-sidebar--overlay'); - sync(); - }); - } - function sync() { - var collapsed = - html.getAttribute('data-td-shell-sidebar') === 'collapsed'; - var drawerOpen = - html.getAttribute('data-td-shell-drawer') === 'open'; - // OINK owns the persistent collapsed mode and pointer overlay. Keep - // focus inside an open preview safe when its native pointerleave fires. - if (desktop.matches && collapsed && - !sidebar.classList.contains('td-shell-sidebar--overlay') && - sidebar.contains(documentObject.activeElement)) { - sidebar.classList.add('td-shell-sidebar--overlay'); - } - var overlay = sidebar.classList.contains('td-shell-sidebar--overlay'); - var isolated = desktop.matches ? collapsed && !overlay : !drawerOpen; - edge.hidden = !desktop.matches || !collapsed; - if (restore) restore.setAttribute('aria-expanded', String(!isolated)); - if (restore) restore.hidden = !desktop.matches || !collapsed; - sidebar.inert = isolated; - if (isolated) sidebar.setAttribute('aria-hidden', 'true'); - else sidebar.removeAttribute('aria-hidden'); - if ( - isolated && - sidebar.contains(documentObject.activeElement) && - restore && - restore.offsetParent !== null - ) { - restore.focus(); + function persist() { + if (!storage) return; + var expanded = buttons.filter(function (button) { + var item = button.closest('li'); + var state = sidebar.getState(button.getAttribute('aria-controls')); + return state && state.expanded && + !(item && item.classList.contains('td-active-path')); + }).map(function (button) { return button.getAttribute('aria-controls'); }); + try { storage.setItem(key, JSON.stringify(expanded)); } + catch (_) { /* Storage can become unavailable after initialization. */ } } - } - - new MutationObserver(sync).observe(html, { - attributes: true, - attributeFilter: ['data-td-shell-sidebar', 'data-td-shell-drawer'], - }); - new MutationObserver(sync).observe(sidebar, { attributes: true, attributeFilter: ['class'] }); - desktop.addEventListener('change', sync); - documentObject - .querySelectorAll('[data-td-shell-sidebar-toggle], [data-td-shell-drawer-close]') - .forEach(function (button) { - button.addEventListener('click', function () { - var hadFocus = documentObject.activeElement === button; - // Match OINK's cooldown so a newly visible trigger under the pointer - // does not immediately undo an explicit keyboard collapse. - pointerLockUntil = Date.now() + 150; - // Run after OINK's native click listener regardless of chunk order. - global.setTimeout(function () { - if (dynamic() && sidebar.contains(button) && restore) { - restore.hidden = false; - restore.focus(); - sidebar.classList.remove('td-shell-sidebar--overlay'); - } - sync(); - // Both external restore controls disappear when pinned. Transfer - // their focus after making the sidebar operable; hover and clicks - // that did not focus a trigger must not steal unrelated focus. - if (hadFocus && desktop.matches && !dynamic() && - !sidebar.contains(button) && button.offsetParent === null) { - var collapse = sidebar.querySelector('.td-shell-sidebar__collapse'); - if (collapse) collapse.focus(); - } - }, 0); - }, true); + documentObject.addEventListener('oink:sidebar-disclosure', function (event) { + if (event.detail && event.detail.source === 'user' && valid.has(event.detail.id)) persist(); }); - sync(); + if (!hasSavedState || legacyState) persist(); + }); } function initScrollableTables(documentObject) { @@ -455,7 +278,6 @@ consumeVersionFallback(windowObject, documentObject, config); initVersionSwitching(windowObject, documentObject); initTreePersistence(windowObject, documentObject, config); - initSidebarIsolation(windowObject, documentObject); initScrollableTables(documentObject); initSearchRetry(windowObject, documentObject); } @@ -464,7 +286,7 @@ init: init, readConfig: readConfig, safeStorage: safeStorage, - setTreeExpanded: setTreeExpanded, + initTreePersistence: initTreePersistence, versionTarget: versionTarget, initVersionSwitching: initVersionSwitching, consumeVersionFallback: consumeVersionFallback, diff --git a/assets/js/kapa-adapter.js b/assets/js/kapa-adapter.js index b5d1913437..0cda8dd6f8 100644 --- a/assets/js/kapa-adapter.js +++ b/assets/js/kapa-adapter.js @@ -70,6 +70,7 @@ 'data-source-group-ids-include': config.sourceGroupId, 'data-language': config.locale, 'data-project-name': 'Apache HugeGraph', + 'data-project-logo': '/img/logo.svg', 'data-project-color': config.themeColor, 'data-project-color-dark': mixWithWhite(config.themeColor, 48), 'data-surface-color': '#ffffff', @@ -90,6 +91,7 @@ 'data-font-family': '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif', 'data-modal-content-border-radius': '12px', + 'data-modal-z-index': '1050', 'data-modal-content-border': '1px solid #d9d4e4', 'data-modal-content-border-dark': '1px solid #494254', 'data-launcher-button-hidden': 'true', @@ -115,6 +117,7 @@ var lastTrigger = null; var activeScript = null; var activeQueue = null; + var operation = null; var status = documentObject.querySelector('[data-hg-ai-status]'); function renderState(next, message) { @@ -133,13 +136,29 @@ } } + function settle(error) { + if (!operation) return; + var current = operation; + operation = null; + current.signal.removeEventListener('abort', current.abort); + if (error) current.reject(error); + else current.resolve(); + } + function openWidget(query, submit) { - invokeKapa(windowObject, 'setSourceGroupIDs', [config.sourceGroupId]); - invokeKapa(windowObject, 'open', { - mode: 'ai', - query: query, - submit: submit, - }); + try { + invokeKapa(windowObject, 'setSourceGroupIDs', [config.sourceGroupId]); + invokeKapa(windowObject, 'open', { + mode: 'ai', + query: query, + submit: submit, + }); + settle(); + } catch (_) { + discardAttempt(attempt); + renderState('error', config.labels.error); + settle(new Error(config.labels.error)); + } } function discardAttempt(serial) { @@ -172,6 +191,7 @@ windowObject.clearTimeout(timer); discardAttempt(serial); renderState('error', config.labels.error); + settle(new Error(config.labels.error)); } function ready(serial, query, submit) { @@ -230,7 +250,7 @@ openWidget(query, Boolean(submit && query)); return; } - var retrying = state === 'error'; + var retrying = attempt > 0; var serial = ++attempt; renderState('loading', ''); timer = windowObject.setTimeout(function () { @@ -244,23 +264,49 @@ renderState('idle', ''); if (consent && consent.open) consent.close(); restoreFocus(); + settle(); } - function activate(query, submit, trigger) { + function activate(query, submit, trigger, context) { if (state === 'loading' || state === 'consent') return; + if (context && context.signal.aborted) return Promise.resolve(); lastTrigger = trigger || documentObject.activeElement; + var completion; + if (context) { + completion = new Promise(function (resolve, reject) { + operation = { signal: context.signal, resolve: resolve, reject: reject }; + operation.abort = function () { + windowObject.clearTimeout(timer); + if (state === 'loading') discardAttempt(attempt); + attempt += 1; + pending = null; + if (consent && consent.open) consent.close(); + renderState('idle', ''); + settle(); + }; + context.signal.addEventListener('abort', operation.abort, { once: true }); + }); + // Transfer focus before opening a dialog; OINK keeps cancellation alive + // until this promise settles, including when search is opened again. + if (!context.handoff()) { + settle(); + return completion; + } + } if (consented) { load(query, submit); - return; + return completion; } // Fail closed if the local consent panel is unavailable. if (!consent || typeof consent.showModal !== 'function') { renderState('error', config.labels.error); - return; + settle(new Error(config.labels.error)); + return completion; } pending = { query: trimmedQuery(query), submit: submit }; renderState('consent', ''); consent.showModal(); + return completion; } if (consent) { @@ -306,119 +352,48 @@ 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) { + documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(function (button) { if (button.dataset.hgAiBound !== undefined) return; button.dataset.hgAiBound = ''; button.addEventListener('click', function () { - controller.activate( - button.dataset.hgAiQuery || '', - button.dataset.hgAiSubmit === 'true', - button, - ); + controller.activate('', false, button); }); - } - documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(bind); - - if (!input || !list) return controller; + }); - // Keep the OINK palette untouched: only intercept Enter when local search - // is empty and the site-owned Ask AI tail is the available follow-up. - input.addEventListener('keydown', function (event) { - if (event.isComposing || event.keyCode === 229 || event.key !== 'Enter') return; - var empty = list.querySelector('.td-shell-search__empty'); - var localRow = list.querySelector('.td-shell-search__item:not(.hg-ai-search-tail__button)'); - var tailButton = list.querySelector('[data-hg-ai-search-tail] [data-hg-ask-ai]'); - if (!empty || localRow || !tailButton) return; - event.preventDefault(); - event.stopImmediatePropagation(); - controller.activate(input.value, true, tailButton); - }, true); - - 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 empty = list.querySelector('.td-shell-search__empty'); - if (empty && config.labels.noResults) empty.textContent = config.labels.noResults; - var oldButton = old && old.querySelector('[data-hg-ask-ai]'); - if (oldButton && oldButton.dataset.hgAiQuery === query) return; - if (old) old.remove(); - - var group = documentObject.createElement('div'); - group.className = 'td-shell-search__group hg-ai-search-tail'; - group.dataset.hgAiSearchTail = ''; - group.setAttribute('role', 'group'); - var label = documentObject.createElement('div'); - label.className = 'td-shell-search__group-label'; - label.textContent = config.labels.ask; - var row = documentObject.createElement('button'); - row.type = 'button'; - row.className = 'td-shell-search__item hg-ai-search-tail__button'; - row.dataset.hgAskAi = ''; - row.dataset.hgAiQuery = query; - row.dataset.hgAiSubmit = 'true'; - var icon = documentObject.createElement('i'); - icon.className = - 'fa-solid fa-wand-magic-sparkles td-shell-search__item-icon'; - icon.setAttribute('aria-hidden', 'true'); - var meta = documentObject.createElement('span'); - meta.className = 'td-shell-search__item-meta'; - var title = documentObject.createElement('span'); - title.className = 'td-shell-search__item-title'; - title.textContent = config.labels.ask + ': “' + query + '”'; - var detail = documentObject.createElement('span'); - detail.className = 'td-shell-search__item-ref'; - detail.textContent = config.historical ? config.labels.latest + '.' : ''; - meta.appendChild(title); - meta.appendChild(detail); - row.appendChild(icon); - row.appendChild(meta); - group.appendChild(label); - group.appendChild(row); - list.appendChild(group); - bind(row); - } - - if (list) { - new MutationObserver(function () { - if (syncing) return; - syncing = true; - windowObject.requestAnimationFrame(syncTail); - }).observe(list, { childList: true, subtree: true }); - input.addEventListener('input', syncTail); - syncTail(); + var palette = windowObject.OinkCommandPalette; + if (palette && typeof palette.registerSearchTail === 'function') { + palette.registerSearchTail({ + id: 'hugegraph-ai', + rows: function (context) { + return [{ + id: 'ask', + title: config.labels.ask + ': “' + context.query + '”', + description: config.historical ? config.labels.latest + '.' : '', + icon: 'fa-solid fa-wand-magic-sparkles', + }]; + }, + activate: function (_, context) { + // OINK debounces row rendering; Enter can activate an older row. + var input = documentObject.querySelector('#td-shell-search .td-shell-search__input'); + var query = trimmedQuery(input ? input.value : context.query); + if (query !== context.query) { + // Refresh OINK's cancellation token as well as the submitted text; + // otherwise its pending render aborts a handoff for the old query. + var instance = palette.instance; + instance.render(query); + var rows = instance.rows(); + var index = rows.findIndex(function (row) { + return row.type === 'extension' && row.owner.id === 'hugegraph-ai'; + }); + if (index >= 0) instance.activate(index); + return; + } + if (!query || query.charAt(0) === '>') return; + // Search options are ephemeral and become hidden on handoff. + var trigger = documentObject.querySelector('.hg-ask-ai-launcher'); + return controller.activate(query, true, trigger, context); + }, + }); } return controller; } diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index 9ebca8f7c0..29fbad69f6 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -507,30 +507,6 @@ display: none; } -.td-nav-util.hg-sidebar-restore { - display: none; -} - -.hg-sidebar-edge { - position: fixed; - inset-inline-start: 0; - top: 64px; - bottom: 0; - width: 16px; - z-index: 121; - border-inline-start: 3px solid var(--bs-border-color); - - &:hover { border-inline-start-color: var(--bs-primary); } -} - -@media (min-width: 768px) { - [data-td-shell-sidebar='collapsed'] { - .td-nav-util.hg-sidebar-restore:not([hidden]) { - display: inline-flex; - } - } -} - .hg-version-overflow { color: var(--bs-body-color); summary { @@ -556,42 +532,6 @@ font-size: 0.84rem; } -.hg-ai-search-tail { - position: sticky; - bottom: 0; - z-index: 1; - margin-inline: -6px; - padding-inline: 6px; - background: var(--td-shell-popover); - box-shadow: 0 -8px 16px color-mix(in srgb, var(--td-shell-popover) 88%, transparent); - 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; - } - } -} - -/* Keep the optional Ask AI tail discoverable without changing the OINK theme. */ -.td-shell-search__panel { - max-height: unquote('min(84vh, 720px)'); -} - -@media (max-width: 767.98px) { - .td-shell-search__panel { - max-height: unquote('min(86vh, 680px)'); - } -} - .hg-ask-ai-launcher { position: fixed; z-index: 1040; diff --git a/contribution.md b/contribution.md index cd95f7ce47..3b239726c4 100644 --- a/contribution.md +++ b/contribution.md @@ -14,23 +14,23 @@ For the short workflow, start with [README.md](./README.md). This file records t The site uses the Hugo Module recorded in `go.mod` and `go.sum`: -```text -Go: 1.27.0 or newer -Hugo Extended: 0.165.0 -OINK: v1.0.0 -``` - -Node.js, npm, PostCSS, and a vendored Docsy checkout are not part of the build. +Toolchain versions are maintained in `go.mod` and `.github/workflows/hugo.yml`; +`go.mod` / `go.sum` pin the theme. Node.js is needed only for the browser tests, +with its version declared by `tests/e2e/package.json` and CI. -Verify the resolved theme before editing: +Verify the installed toolchain and locked module: ```bash hugo version go version -hugo mod graph +python3 scripts/oink_module.py ``` -The module graph must contain exactly the pinned `github.com/pgsty/oink@v1.0.0` dependency for this site. +For theme updates and breaking-change recovery, follow +[scripts/oink-upgrade.md](scripts/oink-upgrade.md). + +For Kapa staging, consent/CSP checks and production activation, follow +[scripts/kapa-rollout.md](scripts/kapa-rollout.md). ## Local preview @@ -65,13 +65,26 @@ 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. +## CI queue and reruns + +Each PR has its own concurrency group; a new commit cancels its previous run. +Cancelled runs skip report uploads and the final gate instead of holding the +queue with `always()`. A queued job with no runner has not started testing; +repeated reruns do not resolve runner capacity shortages. + +Version builds remain parallel. Site assembly and blocking browser tests share +one runner and the same local artifact. The required `deploy` check still +requires all blocking jobs to succeed; visual captures remain advisory and +publication alone receives write permission. For a test failure, rerun failed +jobs after inspecting the cause; artifact names remain stable within the run. + ## Repository structure - `content/en/` and `content/cn/` contain the bilingual source pages. - `hugo.yaml` owns routing, languages, outputs, search, navigation, and OINK parameters. - `data/home/.yaml` owns the bilingual homepage. - `data/footer/.yaml` owns the bilingual footer. -- `i18n/zh-CN.yaml` carries the Simplified Chinese OINK interface catalogue for the preserved `cn` URL language key; the file is named after the `zh-CN` locale because Hugo resolves translations by locale, not by the URL key. +- `i18n/zh-CN.yaml` carries only HugeGraph-specific Simplified Chinese labels; generic interface translations come from OINK for the preserved `cn` URL language key; the file is named after the `zh-CN` locale because Hugo resolves translations by locale, not by the URL key. - `assets/` and `static/` contain site-owned brand and compatibility assets. OINK is a module dependency. Do not copy or edit generated module-cache files. Site-specific overrides belong in the corresponding root `layouts/`, `assets/`, or data path and require focused regression evidence. diff --git a/go.mod b/go.mod index 685c7ce27f..d4cb4ccab0 100644 --- a/go.mod +++ b/go.mod @@ -2,5 +2,4 @@ module github.com/apache/hugegraph-doc go 1.27.0 -require github.com/pgsty/oink v1.0.0 - +require github.com/pgsty/oink v1.1.0 diff --git a/go.sum b/go.sum index 8bd5c21c7b..946ebedd7e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,2 @@ -github.com/pgsty/oink v1.0.0 h1:E+WHFP9zSRT+5RKoIkWNp+ASRGS1BKG+rDEi9by/BjE= -github.com/pgsty/oink v1.0.0/go.mod h1:pHvbUhJCfseB41n5RGwsF7abT3i32VSTpofLQoq4b7Y= - +github.com/pgsty/oink v1.1.0 h1:121L5g57ChRCPyidzEBBcln2Co+0zYRQ+XDDXjymd0Q= +github.com/pgsty/oink v1.1.0/go.mod h1:pHvbUhJCfseB41n5RGwsF7abT3i32VSTpofLQoq4b7Y= diff --git a/hugo.yaml b/hugo.yaml index 93a313c006..b5c402a1be 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -105,15 +105,15 @@ 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. + # Consent-gated Kapa; set false to disable. Rollout evidence and source + # readiness are tracked in scripts/kapa-rollout.md and issue #495. ai_search: - enabled: false + enabled: true provider: kapa website_id: 0b277570-4740-451e-96fa-1e4ac1ac5e88 source_groups: - en: '' - cn: '' + en: 32769e2a-abfc-42f6-891f-09af6c123eb2 + cn: ddce6795-32c8-40ac-8b27-73d73facd042 footer_center_info: '' # The compact ASF footer owns the legal line; global controls stay in header. copyright: false diff --git a/i18n/en.yaml b/i18n/en.yaml index 2544995466..ad2a00080f 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -2,7 +2,6 @@ ui_version_fallback: This page is not available in the selected version. You hav ui_ask_ai: Ask AI ui_ask_ai_description: Powered by Kapa; only your question is sent. ui_ask_ai_latest: Answers use the latest documentation -ui_ask_ai_no_results: No results found. Try AI mode. ui_retry: Retry ui_ai_error: AI is temporarily unavailable. Local search is unaffected. ui_more_versions: More versions @@ -20,7 +19,6 @@ download_component_ai: AI download_component_computer: Computer download_component_commons: Common download_asf_note: Source archives are the official Apache Software Foundation releases; binary packages are convenience builds made from them. All files are served from ASF mirrors, with signatures and checksums hosted on downloads.apache.org. Source archives generated automatically by GitHub are not ASF releases. -ui_assets_download: Download asset ui_ai_consent_description: "Continuing loads Kapa, a third-party service. Your question will be sent to Kapa to generate an answer." ui_ai_consent_continue: "Continue to Ask AI" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index f373dc0d91..a75023877f 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -1,244 +1,8 @@ -# Alert labels -callout_caution: 注意 -callout_important: 重要 -callout_note: 说明 -callout_tip: 提示 -callout_warning: 警告 -callout_success: 成功 -callout_danger: 危险 -callout_question: 问题 -callout_example: 示例 -callout_quote: 引用 -callout_details: 详情 - -# UI strings. Buttons and similar. -ui_pager_prev: 上一页 -ui_pager_next: 下一页 -ui_search: 搜索… -ui_search_empty: 未找到结果 -ui_search_loading: 正在加载搜索索引…… -ui_search_results: '找到 {count} 条结果' -ui_search_nav: 选择 -ui_search_open: 打开 -ui_search_close: 关闭 -ui_palette_actions: 操作 -ui_palette_page_actions: 页面操作 -ui_palette_preferences: 偏好设置 -ui_palette_commands: 命令 -ui_palette_quick_links: 快速链接 -ui_palette_no_commands: 未找到匹配命令 -ui_palette_choose: 请选择一项 -ui_palette_action_failed: 无法完成此操作 -ui_palette_pages: 页面 -ui_palette_index_unavailable: 页面索引暂不可用,操作仍可使用 -ui_sidebar_nav: 章节导航 -ui_heading_self_link: 标题锚点 -ui_field_self_link: 链接到此字段 -ui_preview_source: Markdown -ui_preview_rendered: 渲染结果 -ui_main_nav: 主导航 -ui_home: 首页 -ui_sidebar_expand: 展开侧栏 -ui_sidebar_collapse: 收起侧栏 -ui_drawer_open: 打开导航 -ui_drawer_close: 关闭导航 -ui_root_menu_label: 选择栏目 -ui_tags_title: 标签 -ui_tag_title: 标签 -ui_categories_title: 分类 -ui_category_title: 分类 -ui_modules_title: 模块 -ui_module_title: 模块 -ui_authors_title: 作者 -ui_author_title: 作者 -ui_theme_toggle: 切换配色 -ui_language: 语言切换 -ui_theme: 主题颜色 -ui_theme_auto: 跟随系统 -ui_theme_light: 亮色 -ui_theme_dark: 暗色 -ui_toc_hide: 隐藏目录 -ui_toc_show: 显示目录 -ui_language_select: 选择语言 -ui_language_switch: 切换语言 -ui_skip_to_content: 跳转到主要内容 -ui_page_actions: 操作 -ui_open_in_chatgpt: 在 ChatGPT 中打开 -ui_open_in_claude: 在 Claude 中打开 -ui_open_in_prompt: 请阅读 %s 的内容,以便我就此向你提问。 -ui_copy_markdown: 复制 Markdown 文本 -ui_copy_success: 已复制 Markdown -ui_copy_error: 无法复制 Markdown -ui_share: 分享 -ui_share_email: 邮件 -ui_copy_link: 复制链接 -ui_copy_link_success: 已复制链接 -ui_copy_link_error: 无法复制链接 -ui_code_copy: 复制 -ui_code_copy_label: 复制代码 -ui_code_copied: 已复制 -ui_code_copy_error: 复制失败 -ui_code_show_all: 显示全部 {{ .Count }} 行 -ui_code_collapse: 收起代码 -ui_tabs_label: 选项卡 -ui_pricing_featured: 推荐 -ui_pricing_included: 包含 -ui_pricing_excluded: 不包含 -ui_marquee_pause: 暂停滚动 -ui_kbd_with: 加 -ui_keyboard_shortcuts: 键盘快捷键 -ui_shortcut_tree_move: 在侧栏中移动 -ui_shortcut_tree_toggle: 收起或展开章节 -ui_shortcut_tree_open: 打开当前页面 -ui_shortcut_heading_move: 上一个或下一个标题 -ui_shortcut_page_move: 上一页或下一页 -ui_shortcut_search: 搜索 -ui_shortcut_commands: 命令面板 -ui_shortcut_reading_mode: 专注阅读模式 -ui_shortcut_language: 切换语言 -ui_shortcut_theme: 切换主题 -ui_shortcut_route: 切换顶层页面 -ui_page_annotation: 页面信息 -ui_backlinks: 反链 -ui_backlinks_more: "再显示 {{ . }} 条" -ui_field_required: 必填 -ui_action_unavailable: 暂不可用 -ui_image_zoom_dialog: 图片预览 -ui_image_zoom_open: 打开图片预览 -ui_image_zoom_close: 关闭图片预览 -ui_print_page: 打印此页面 -ui_sidebar_expand_section: 展开章节 -ui_sidebar_collapse_section: 收起章节 -ui_asciinema_timer: 播放时间 -ui_openapi_spec: OpenAPI 规格文件 -ui_release_view: 查看发布 -ui_release_source: 源码 -ui_release_compare: 比较变更 -ui_release_released: 发布于 -ui_release_checksums: 校验和 -ui_assets_file: 文件 -ui_assets_checksum: 校验和 -ui_assets_copy: 复制校验和 -ui_assets_copied: 已复制 -ui_assets_copy_all: 复制全部校验和 -ui_assets_download: 下载资产 -ui_download_channels: 下载渠道 -ui_download_unpublished: 待发布 - -# Used in sentences such as "All Tags" -ui_all: 全部 - -# Footer text -footer_all_rights_reserved: 保留所有权利 - -ui_footer_collapse: 收起页脚链接 -ui_footer_expand: 展开页脚链接 - -# Post (blog, article, etc.) -post_last_mod: 最后修改 -post_upstream: "{{ .work }},{{ .copyright }},依 {{ .license }} 授权使用,详见{{ .notice }}。" -post_upstream_adapted: 本页改编自 {{ .work }},{{ .copyright }},依 {{ .license }} 授权,详见{{ .notice }}与{{ .history }}。 -post_upstream_adapted_plain: 本页改编自 {{ .work }},{{ .copyright }},依 {{ .license }} 授权,详见{{ .notice }}。 -post_upstream_notice: 归属声明 -post_upstream_history: 修改历史 -post_translated: 本页为译文,如有出入以{{ .original }}为准。 -post_translated_original: 原文 -post_edit_this: 编辑当前页面 -post_view_markdown: 查阅 Markdown 源码 -post_create_child_page: 添加子页面 -post_create_issue: 提交文档议题 -post_create_project_issue: 提交项目议题 -post_reading_time: 分钟阅读 -post_less_than_a_minute_read: 1分钟内可阅读完 -post_word_count: '{{ .Count }} 字' -post_reading_minutes: '{{ .Minutes }} 分钟' -post_read_original: 阅读原文 - -# Print support -print_printable_section: 这是本节的多页打印视图。 -print_click_to_print: 点击此处打印 -print_show_regular: 返回本页常规视图 -print_entire_section: 打印完整章节 - -# Feedback -feedback_question: 这篇文档解决了你的问题吗? -feedback_positive: 是 -feedback_negative: 否 -feedback_thanks: 感谢反馈,我们会据此改进这篇文档。 -feedback_reason_prompt: 哪里没有解决?(可选) -feedback_reason_missing: 缺少必要信息 -feedback_reason_outdated: 内容错误或已过时 -feedback_reason_failed: 操作步骤没有生效 -feedback_reason_unclear: 内容难以理解 -feedback_details: 在评论区补充详情 -feedback_change: 修改反馈 - -# Table of contents -toc_on_this_page: 目录 - -# Error pages -error_404_title: 页面不存在 -error_404_body: >- - 抱歉,该页面不存在。可以从首页重新开始。 -error_404_home: 返回首页 - -# Version banner -version_banner_archived: >- - 文档 {{ .Version }} 版本已不再积极维护,你当前浏览的是一份归档快照。 -version_banner_latest: 最新文档请参阅 {{ .Link }}。 -version_banner_latest_link: 最新版本 - -# Comments -comments_noscript: 加载评论需要启用 JavaScript。 -comments_noscript_link: 在 GitHub 上查看讨论。 - -# LLM 页面操作 -ui_open_in_prompt_label: 就本页提问 -ui_view_history: 查阅编辑历史 -ui_table_scroll: "可横向滚动的表格" -ui_filetree_divider: "拖动以调整目录树备注列宽度" -book_figure: 图 -book_table: 表 -book_equation: 公式 -book_example: 示例 -book_toc: 全书目录 -book_draft: 草稿 -book_draft_notice: 本章仍在修订中。 -contributors_count: 位贡献者 - -# Article series -ui_series_title: 系列 -ui_series_part: 第 {{ .Part }} / 共 {{ .Total }} 篇 - -# Replace these values with reviewed translations when available. -ui_list_separator: 、 - -# Footer text -post_meta_by_in: "{{ .Authors }} 发布于 {{ .Section }}" -post_meta_in: "发布于 {{ .Section }}" - -# Replace these values with reviewed translations when available. -ui_blog_index_toggle: 切换布局 - -# Markdown output -markdown_llms_index: "LLMS 索引:" -markdown_backlinks: "反链:" -markdown_section_pages: "本节页面:" - -# Explicit English fallbacks for untranslated OINK UI strings. -# Replace these values with reviewed translations when available. -ui_diagram_expand: 放大图表 -ui_diagram_zoom_dialog: 图表预览 -ui_diagram_zoom_close: 关闭图表预览 -ui_diagram_zoom_in: 放大 -ui_diagram_zoom_out: 缩小 -ui_diagram_zoom_reset: 复位视图 -ui_diagram_error: 图表无法渲染 +# HugeGraph-specific labels. Generic UI translations come from OINK. ui_version_fallback: 目标版本没有此页面,已转到该版本的文档首页。 ui_ask_ai: 询问 AI ui_ask_ai_description: 由 Kapa 提供;仅发送你的问题。 ui_ask_ai_latest: 回答基于 latest 文档 -ui_ask_ai_no_results: 未找到结果,可以试试 Ask AI 模式。 ui_retry: 重试 ui_ai_error: AI 暂时不可用,本地搜索不受影响。 ui_more_versions: 更多版本 diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html index fff6e6dd25..06d57502bb 100644 --- a/layouts/_partials/hooks/body-end.html +++ b/layouts/_partials/hooks/body-end.html @@ -24,8 +24,6 @@ "ask" (T "ui_ask_ai") "description" (T "ui_ask_ai_description") "latest" (T "ui_ask_ai_latest") - "noResults" (T "ui_ask_ai_no_results") - "retry" (T "ui_retry") "error" (T "ui_ai_error") -}} {{- $clientConfig := dict diff --git a/layouts/_partials/navbar-item.html b/layouts/_partials/navbar-item.html index d07dd32d2e..74c9b0ca30 100644 --- a/layouts/_partials/navbar-item.html +++ b/layouts/_partials/navbar-item.html @@ -1,4 +1,4 @@ -{{- /* OINK v1.0.0 override: Documentation gains manifest-driven versions. +{{- /* OINK override (reviewed against v1.1.0): Documentation gains manifest-driven versions. One top-level Hugo Menu item. Leaves keep the historical link path; parents add a separate disclosure and one dropdown/accordion panel. */ -}} {{- $page := .page -}} diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html index 9eeb35de01..74dfc846c1 100644 --- a/layouts/_partials/navbar.html +++ b/layouts/_partials/navbar.html @@ -53,13 +53,6 @@ {{- /* Right zone: the search box leads as the elastic boundary before the fixed controls — version, language, theme, GitHub. */ -}}