You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/09/25 09:43:36 UTC
(hugegraph-doc) branch master updated: feat: upgrade OINK to 1.1 with maintainable site extensions (#496)
This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
The following commit(s) were added to refs/heads/master by this push:
new cd6e442fe feat: upgrade OINK to 1.1 with maintainable site extensions (#496)
cd6e442fe is described below
commit cd6e442fe22c034e70e5b4e85f77eb68cd0ebec5
Author: imbajin <ji...@apache.org>
AuthorDate: Fri Sep 25 17:43:32 2026 +0800
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(-)
diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml
index adfc30014..50cf9928b 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 295223cc2..a53d7c803 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 c4a054515..6e8981708 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 91cb6e33a..c7bce531b 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 6b2b71db2..1e618d088 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 09240eb54..2cbf2e702 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 b5d191343..0cda8dd6f 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 9ebca8f7c..29fbad69f 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 cd95f7ce4..3b239726c 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/<language>.yaml` owns the bilingual homepage.
- `data/footer/<language>.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 685c7ce27..d4cb4ccab 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 8bd5c21c7..946ebedd7 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 93a313c00..b5c402a1b 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 254499546..ad2a00080 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 f373dc0d9..a75023877 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 fff6e6dd2..06d57502b 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 d07dd32d2..74c9b0ca3 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 9eeb35de0..74dfc846c 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. */ -}}
<div class="td-nav-links td-nav-util-zone">
- {{- if $drawerMode }}
- <button class="td-nav-util hg-sidebar-restore" type="button" hidden
- data-td-shell-sidebar-toggle title="{{ T "ui_sidebar_expand" }}"
- aria-label="{{ T "ui_sidebar_expand" }}" aria-controls="td-shell-sidebar">
- {{- partialCached "shell/icon.html" "panel-left" "sidebar-restore" -}}
- </button>
- {{- end }}
{{- if $localSearch }}
<button type="button" class="td-nav-search-box" data-td-shell-search-open
title="{{ T "ui_search" }}" aria-label="{{ T "ui_search" }}"
diff --git a/layouts/_partials/print/page-content.html b/layouts/_partials/print/page-content.html
index 2234924f8..b616a7938 100644
--- a/layouts/_partials/print/page-content.html
+++ b/layouts/_partials/print/page-content.html
@@ -1,8 +1,22 @@
+{{- /* One cache key owns both Print render modes for a Book page. Hugo may
+ render the plain page and overlapping Book aggregates in parallel, so
+ computing the variants in a fixed order keeps tdBookAggregate local to
+ this coordinator instead of racing two differently keyed partials. */ -}}
{{- $page := .page -}}
{{- $page.Store.Set "tdOutputFormat" "print" -}}
-{{- if .book }}{{ $page.Store.Set "tdBookAggregate" true }}{{ end -}}
-{{- $content := $page.RenderString (dict "display" "block") $page.RawContent -}}
-{{- $content = partial "content/static-image-output.html" $content -}}
-{{- partial "code/register-rendered-ids.html" (dict "page" $page "content" $content) -}}
-{{- if .book }}{{ $page.Store.Delete "tdBookAggregate" }}{{ end -}}
-{{- return (printf "<template data-hg-authored-content=\"start\"></template>%s<template data-hg-authored-content=\"end\"></template>" $content | safeHTML) -}}
+{{- $page.Store.Delete "tdBookAggregate" -}}
+{{- $plain := $page.RenderString (dict "display" "block") $page.RawContent -}}
+{{- $plain = partial "content/static-image-output.html" $plain -}}
+{{- partial "code/register-rendered-ids.html" (dict "page" $page "content" $plain) -}}
+{{- $book := $plain -}}
+{{- if eq $page.Type "book" -}}
+ {{- $page.Store.Set "tdBookAggregate" true -}}
+ {{- $book = $page.RenderString (dict "display" "block") $page.RawContent -}}
+ {{- $book = partial "content/static-image-output.html" $book -}}
+ {{- partial "code/register-rendered-ids.html" (dict "page" $page "content" $book) -}}
+ {{- $page.Store.Delete "tdBookAggregate" -}}
+{{- end -}}
+{{- /* Keep publication scanning scoped to authored content in both modes. */ -}}
+{{- $plain = printf "<template data-hg-authored-content=\"start\"></template>%s<template data-hg-authored-content=\"end\"></template>" $plain | safeHTML -}}
+{{- $book = printf "<template data-hg-authored-content=\"start\"></template>%s<template data-hg-authored-content=\"end\"></template>" $book | safeHTML -}}
+{{- return (dict "plain" $plain "book" $book) -}}
diff --git a/layouts/_partials/shell/config.html b/layouts/_partials/shell/config.html
index bc01845f0..b29726876 100644
--- a/layouts/_partials/shell/config.html
+++ b/layouts/_partials/shell/config.html
@@ -1,4 +1,4 @@
-{{- /* OINK v1.0.0 override: separate the visible lockup from the SEO title. */ -}}
+{{- /* OINK override (reviewed against v1.1.0): separate the visible lockup from the SEO title. */ -}}
{{- $name := .Site.Params.brand_name | default .Site.Title -}}
{{- $logoPath := .Site.Params.logo | default "icons/logo.svg" -}}
{{- $logoURL := (partial "content/url.html" (dict
diff --git a/layouts/_partials/shell/sidebar-panel.html b/layouts/_partials/shell/sidebar-panel.html
index e4a57af25..b6c4bc6ba 100644
--- a/layouts/_partials/shell/sidebar-panel.html
+++ b/layouts/_partials/shell/sidebar-panel.html
@@ -1,4 +1,4 @@
-{{- /* OINK v1.0.0 override: the mobile Docs drawer owns global controls that
+{{- /* OINK override (reviewed against v1.1.0): the mobile Docs drawer owns global controls that
disappear from the compact header and no longer repeat in the footer. */ -}}
{{- $config := partialCached "shell/config.html" . .Site.Language.Lang -}}
{{- $darkMode := partialCached "dark-mode-config.html" "dark-mode-global" -}}
diff --git a/layouts/_partials/shell/toc.html b/layouts/_partials/shell/toc.html
index 600866385..d6f2aed5a 100644
--- a/layouts/_partials/shell/toc.html
+++ b/layouts/_partials/shell/toc.html
@@ -1,4 +1,4 @@
-{{- /* OINK v1.0.0 shell/toc.html with an accessible name on Hugo's TOC nav. */ -}}
+{{- /* OINK v1.1.0 shell/toc.html with an accessible name on Hugo's TOC nav. */ -}}
{{- $toc := .TableOfContents -}}
{{- $label := T "toc_on_this_page" | htmlEscape -}}
{{- $toc = replace $toc `<nav id="TableOfContents">` (printf `<nav id="TableOfContents" aria-label="%s">` $label) -}}
diff --git a/scripts/kapa-rollout.md b/scripts/kapa-rollout.md
new file mode 100644
index 000000000..13090fc4d
--- /dev/null
+++ b/scripts/kapa-rollout.md
@@ -0,0 +1,121 @@
+# Kapa 分阶段启用
+
+本流程跟踪 [#495](https://github.com/apache/hugegraph-doc/issues/495)。
+OINK 升级和关闭 AI 的 staging 可以先发布;真实 AI 联调及生产启用需要各自的验收证据。
+模拟测试通过不代表远端索引、域名许可或真实服务已通过。
+
+## 当前配置与授权边界
+
+`hugo.yaml` 中的配置入口是 `params.ai_search`:
+
+| 字段 | 含义与启用要求 |
+| --- | --- |
+| `enabled` | 本 PR 设置为 `true`;合入并发布后启用,回退时设为 `false` |
+| `provider` | 当前仅支持 `kapa` |
+| `website_id` | 已配置公开站点标识;启用前核对它确实属于 HugeGraph 项目 |
+| `source_groups.en` | 真实英文、仅最新文档的 source group ID |
+| `source_groups.cn` | 真实中文、仅最新文档的 source group ID;配置键是 `cn`,widget 语言是 `zh` |
+
+两个 source group ID 已从 Kapa 控制台核对并记录在配置中;这不代表索引范围和真实服务已验收。
+缺失任一 ID 时,开启状态下模板会拒绝构建。
+`tests/e2e/ai-enabled.yaml` 的 `e2e-source-*` 仅用于模拟测试,不能发布为真实配置。
+source group 限制必须在 Kapa 管理端核对索引内容,不能只验证 ID 非空。
+Kapa 子组会继承 Global 和父组的 sources;必须检查整个继承链,不能将分组过滤误认为语言隔离。
+`data-language=zh` 只改变界面文案,不限制检索语料。
+
+EN/CN 组仅保留各自最新文档来源。旧站、历史版本及混合语言来源应从 Global/共同父组
+移至与 EN/CN 同级的 `Legacy` 组,不能让 `Legacy` 成为它们的父组。
+移动后复查两个组实际可检索的来源及引用;创建空子组本身不产生隔离。
+来源可以使用文件上传或限定范围的 HTML 抓取。手动刷新后等待索引完成,并记录文档 SHA、
+语言、更新时间和来源范围。当前项目曾显示 demo 自动刷新限制;手动更新可用,不能据此
+声称已建立自动同步。索引完成也不等于真实问答验收通过。
+
+HTML 抓取分别从 `/docs/` 与 `/cn/docs/` 开始,用锚定到站点和语言目录的 URL 正则
+排除历史路径及其他站点,正文选择器使用 `.td-content`。部署前预览全部页面并核对包含/排除
+列表;保持 Global 关闭,只选对应语言组。验证新来源后,将旧上传移入 Legacy 保留回退能力。
+记录实际抓取站点和候选 SHA;引用会指向抓取站点,staging 来源不能直接当作生产引用配置。
+
+[ASF FAQ](https://privacy.apache.org/faq/committers.html#can-i-use-kapaai-on-our-website-answer-machine)
+确认 Kapa 已签署 DPA,并要求在加载前征得用户同意。本站先展示本地授权弹窗,
+用户同意后才加载第三方脚本;`data-consent-required=false` 用于避免 widget 再次弹窗,
+不表示跳过本站授权。保留禁用分析 cookie、指纹及反馈的现有配置。
+
+[ASF CSP 文档](https://infra.apache.org/tools/csp.html) 要求新增域名有 VP Data Privacy
+批准依据。核对 Kapa 及其 hCaptcha 依赖的具体域名和许可范围,再修改产物根目录的
+`.htaccess`,并在注释中注明依据。不要手改后会被构建覆盖的产物,也不要添加未经核对的通配域名。
+Kapa 服务获准使用不等于任意附带服务或域名都获准。
+**Jira 不是默认前置条件**;只有需要 Infra 协助配置或资源时才请求其处理。
+
+## 当前启用变更
+
+#496 已按维护者要求纳入 `enabled=true` 和既有 staging 验证过的 Kapa/hCaptcha CSP。
+这使合入后的正式站具备技术加载条件;修改 PR 不会立即发布正式站。本站授权弹窗仍保留,
+第三方资源只在用户同意后加载。CSP 注释记录既有部署依据,不宣称已找到独立批准记录。
+用于正式站的 HTML 来源应直接抓取 `https://hugegraph.apache.org/docs/` 与
+`https://hugegraph.apache.org/cn/docs/`,使回答引用保持在正式域名。切换来源前核对
+线上文档与候选内容及路径一致,索引完成后验证实际回答引用;无需在前端改写链接。
+当前索引与问答验收证据统一记录在 #495;开启配置本身不代表验收完成。
+
+## 发布顺序
+
+部署前核对可信 `master` workflow 支持候选分支的主题模块约束。
+现有流程通过模块图验证兼容性,不依赖固定的主题版本号。
+不要绕过 `--ref master`;也不要为了解除 staging 阻塞而先合并主题升级,
+因为 `master` push 会触发正式站发布。
+
+1. **先发布 AI 关闭的 staging。** 使用已审核的 ASF 仓库候选分支,确认其中
+ `params.ai_search.enabled=false`。现有 workflow 从 `master` 调度,参数为
+ `operation=staging-next`、`scope=latest`、`candidate_branch=<候选分支>`、
+ `confirmation=publish asf-staging-oink`,目标是
+ <https://hugegraph-oink.staged.apache.org/>。核对运行解析的 SHA、目标分支
+ `asf-staging-oink` 和实际部署结果;要验证站内历史页面时使用 `scope=full`。
+2. **准备真实配置。** 获取并审核两个 source group、项目 ID、语料范围及域名许可。
+ 当前 workflow 没有 AI 专用开关,不能把测试 fixture 当作 staging 配置。
+ 使用单独的 staging 候选分支承载真实配置和经批准的 CSP 生成改动,
+ 先通过 `staging-next` 验证;生产启用配置在发布 PR 中审核,
+ 不把 staging 特有的站点地址或发布 profile 带入生产。
+3. **真实 staging 验收。** 按下表记录网络、交互和回答证据;存在失败时保留原生搜索,
+ 修复后重新验证受影响项。只做 latest 部署时,历史版本跳转到生产,不能声称已验收
+ staging 历史页面;历史提示需在完整 staging 产物上验证。
+4. **生产开启。** 将已验证的 source groups、CSP 和启用配置纳入发布 PR 审核;
+ 可以与主题升级在同一 PR,但必须先取得 staging 证据,再启用生产。
+ 链接 staging SHA、部署运行及验收记录。合并前确认生产发布目标及回退版本。
+ 发布后复查生产响应头、授权前网络和中英文真实回答。
+
+## 真实浏览器验收
+
+在全新浏览器上下文中使用实际远端服务,不拦截替换 Kapa bundle。为桌面及移动端分别验证。
+
+| 场景 | 必须观察到的结果 |
+| --- | --- |
+| AI 关闭 | 页面、搜索及导航不发送任何第三方 AI 请求 |
+| AI 开启但尚未同意 | 页面加载、输入搜索、打开授权弹窗及取消均无 Kapa/hCaptcha 请求;不能仅检查 cookie |
+| 同意并提问 | bundle 只在同意后加载,真实请求与回答成功;响应头和控制台没有阻止该流程的 CSP 错误 |
+| 中英文 | 英文使用 `.en` group、中文使用 `.cn` group;回答引用来自对应语言的最新文档,不混入历史语料 |
+| 继承与引用 | EN/CN 不再继承旧 Global/父组来源;用各语言真实问题检查返回引用,确认 Legacy 内容未泄漏到回答 |
+| 历史页面 | 明确显示 AI 使用最新文档的提示,不暗示回答适用于当前历史版本 |
+| 取消与焦点 | 取消授权、Escape、重新打开原生搜索不会被过期异步响应再次打开 AI;键盘焦点可继续操作 |
+| 超时与失败 | 网络失败有可理解提示,原生搜索仍可用;重试只触发一次新的加载,成功后可继续提问 |
+| 回退 | 发布 AI 关闭的已知配置后,新页面访问无第三方 AI 请求,原生搜索保持可用 |
+
+现有 `tests/e2e/ai.spec.js` 验证的是模拟 bundle 的适配行为,
+`tests/e2e/platform.spec.js` 覆盖开启状态的授权前零请求,以及独立 disabled fixture
+中的中英文关闭状态。CI 和 OINK 升级验证器都会构建对应夹具;保留这些回归,并另附真实服务证据。
+
+## 记录与回退
+
+在 #495 或启用 PR 中记录下列链接及结果,不把账号、凭据、用户问题或未脱敏 HAR 放入仓库:
+
+- 候选 SHA、workflow run、部署地址、产物/部署 SHA 对应关系。
+- Kapa 项目归属、EN/CN group 的继承链、Legacy 隔离与索引范围审核结果及复核时间。
+- 上传或 HTML 抓取对应的文档 SHA、语言、时间、索引状态;HTML 抓取还需保存 URL 白名单及正文范围。
+ 本地下载的内容哈希用于追溯站点版本,不能冒充 Kapa 内部解析字节的哈希。
+- 具体域名许可依据、部署后 CSP 响应头、脱敏网络记录和浏览器截图。
+- 各验收项通过/失败、失败原因、负责方与下一步;未知项明确写“待验证”。
+
+出现问题时先将对应环境的 `enabled` 设回 `false` 并按原发布流程重建部署,
+或重新部署已知 AI 关闭的候选提交。不要重置其他人的修改;不要通过删除 CSP 限制绕过失败。
+关闭配置影响重新加载后的页面,已经打开的 widget 需要刷新;回退后重新验证关闭状态。
+
+当前 staging 证据及未完成项以 #495 为准。既有 staging 的 CSP 可作为部署先例,
+不能冒充单独的组织批准记录。生产启用仍需独立审核,不因 staging 可用而自动开启。
diff --git a/scripts/oink-overrides.json b/scripts/oink-overrides.json
new file mode 100644
index 000000000..a247fd164
--- /dev/null
+++ b/scripts/oink-overrides.json
@@ -0,0 +1,97 @@
+{
+ "version": "v1.1.0",
+ "files": {
+ "assets/js/command-palette.js": "d6eca1c1475dc333372b7b0178e4ff83eb0078697f39fc33f6c8b268197894f9",
+ "assets/js/docs-shell.js": "beb12db8cf2cbb3dd4b95df50e5b656137de38b957137127dbba0dcfc3d07493",
+ "assets/js/search-engine.js": "61a8ddf80ba0a72bac704305793d03103c7e784af6870f78e470bf1fc47c80dd",
+ "assets/js/search.js": "2afb45a6074881969a9ee1eb1357eea3b49fdcff3710940e7cd18f4d58e821f7",
+ "assets/js/sidebar-nav.js": "668a1b27e2dcc3311b4aa04982325e97b625ca2d160c8322d1151b0dd2387ca8",
+ "assets/js/sidebar-state.js": "cef728b2897f55bf93ec1d1702cdac87b0971fb458cbd2d378a8c66d71e1da8f",
+ "assets/js/surface-coordinator.js": "fa9c633ed305449fe7009caaa6c3e5e24027050a9ac4bcb77261473dc9faedd2",
+ "assets/scss/_styles_project.scss": "65a49b8298575f61a1b03ef0ab95e949ad147b62db9cb386170ecec09fe64e87",
+ "assets/scss/_variables_project.scss": "65a49b8298575f61a1b03ef0ab95e949ad147b62db9cb386170ecec09fe64e87",
+ "assets/scss/_variables_project_after_bs.scss": "65a49b8298575f61a1b03ef0ab95e949ad147b62db9cb386170ecec09fe64e87",
+ "assets/scss/main.scss": "c740f25f00be4b3927233c295602dbfbb5550a701610bd0d383c2220ae9e5596",
+ "assets/scss/redoc.scss": "70db4b7c1bd10d3dfe410badd298f08bee4f6a525c1ca70bbb7d349f931c8a5b",
+ "assets/scss/td/_accessibility.scss": "8ff61c338968b56adf561ba9a02257e62116abee58201c706add042d362a960d",
+ "assets/scss/td/_alerts.scss": "16eef373c0c9d1edc7cd0c926a91c8ca95a918e5082ca285ea8a56afbbf1f8d7",
+ "assets/scss/td/_backlinks.scss": "624a003051a3b2ffb18ba4f85ceba0d76c29574cd12eac9e4164c283a74663b1",
+ "assets/scss/td/_blog.scss": "f917cdd68cd78181a3407163b84c540d08a13de51c2359bc5c13dc44ee414930",
+ "assets/scss/td/_book.scss": "d1febbe605307412b600f56be9aaaac2b279d15b4488498b8c3b347a2dfccd2e",
+ "assets/scss/td/_brand.scss": "2dc0c9d2d9d8763ad13b7dfeaaecce7318dd3b7e9238f3ad85fc15da80fe5c60",
+ "assets/scss/td/_breadcrumb.scss": "19bba95695f4a5f5c12642917649eeb62cb1740591aa42272227dca7b11e9318",
+ "assets/scss/td/_byline.scss": "7e32a9f578aa01ab8c9a8179c2ee8f88dcbf05fedb440f6789f5f6eb5b3527a6",
+ "assets/scss/td/_chroma.scss": "9c2c4ead60b5068d07cbd838f8ac36d04ef746874b2786b5f6b9fbceba3e347a",
+ "assets/scss/td/_code.scss": "18a7da78f71269b23677c37d5d3b7acf11c28a6628d1225d3e81e8b6cd9fadcf",
+ "assets/scss/td/_content-components.scss": "78ed18e1c3591df9df10698ef06733913611546229e7cc7241795c4e54b55c24",
+ "assets/scss/td/_content.scss": "8a08322ced3493ae563922c66f554b1f45a346c101d81e31b7d2b0de7532ca71",
+ "assets/scss/td/_contributors.scss": "523b145aa37f3dae2130ab3720c0e53e44e6c738c5c9b72abd46fab324d50729",
+ "assets/scss/td/_diagram.scss": "588c348450c2692e5bddbf842ecf1eb636429bebadf035f0c512bd4c5dedb1e5",
+ "assets/scss/td/_drawio.scss": "798945819265427291b74572c206c816d99f4b5a3f4e755deb7660887fd68bc8",
+ "assets/scss/td/_filetree.scss": "eeeedcc651e5e64442db83a11eed57b6ee6e001564d9bf142face75fffe58dd0",
+ "assets/scss/td/_footer.scss": "4bacda62f712f852ff7fb18ed975767e78a2ddad3dcf95219824addab279d63b",
+ "assets/scss/td/_gallery.scss": "f8390c4a531efe78df2f8558605abdd2e83a033d5322faf9434cdc729e801944",
+ "assets/scss/td/_giscus.scss": "56232f9d99e37e95394a270892fcf0ba69cee77961b64624f460bbc79ababc64",
+ "assets/scss/td/_landing.scss": "f3e4731d8c646c7aad323f9f1ce94d3b5b0cf5050792dbe95080d51190171fea",
+ "assets/scss/td/_language-selector.scss": "3fb539051f1a77f53281183d293163c49570e6548bf334d6a1c5ac4adff0f638",
+ "assets/scss/td/_main-container.scss": "516932e165600aef8c0a42fd196ee35847a6940bd977ce9244680f5611e01e46",
+ "assets/scss/td/_main.scss": "5257325494fd7d8098dd82ea0d7d00e7fcaf715932292775324f7c70355f3c15",
+ "assets/scss/td/_markers.scss": "265ef8603e4436afa48fe6ac75d42d7f0c7a35ae0a9169c74ea7a5748a4dcc1f",
+ "assets/scss/td/_pager.scss": "1d0cc461a67f02be83933e09218eed3e327b8d0ad550c3c56c18715dfe59b749",
+ "assets/scss/td/_print.scss": "4a74df65173129c41e8f686b62a243e77eaa56b739654aab2ec4df8ad39624f3",
+ "assets/scss/td/_release.scss": "1a447610cef9b39f704430a778776269d693c3ca914fb96aa94c59d8bf792a84",
+ "assets/scss/td/_scroll.scss": "1cfae1c523f1d4954427112da812d6689340b6ae772074b2bf58817b36cd5377",
+ "assets/scss/td/_search.scss": "261c801379a103b9eebd54729c94ed614954bb59cad2630d718f36401e5ca22c",
+ "assets/scss/td/_section-index.scss": "4862e58dcf7afa0ad7e2a6fc36222f9e228ee12a9ea1726530a29c0d3333ec49",
+ "assets/scss/td/_share.scss": "3b1d1d4c276c06e6f9222e86a9203598f70df75589214f5eadf8f8e73158cf9f",
+ "assets/scss/td/_shortcodes.scss": "0aea03fb5a146c30508cadf1e009d0cfc74a1647745dd8cf5a0f43a0b0f7be3d",
+ "assets/scss/td/_site-navbar.scss": "d4d9e799cf99f628aaa0a70cefdca65321458ef798b34304d790f878b2cad888",
+ "assets/scss/td/_swagger.scss": "70b1ab99d15c9029d4a75ff05dec9296a4def1254b722a6d6f7cca5b1004bfd4",
+ "assets/scss/td/_table.scss": "3e05e7970ca2091824366260f76a7b2b5d3c28a0e17ee427d0d0575361b109bf",
+ "assets/scss/td/_tabs.scss": "0457a9fb45de324817bac5f071cf82ff91d05ac6a4900dc5265c0d89505eaeda",
+ "assets/scss/td/_taxonomy.scss": "489a1615d1237670c1d158e070681c3e45a5ad0de7f59502661c0066222273e5",
+ "assets/scss/td/_theme-toggle.scss": "ca600d04786f0fc26a32c8dd5a3c5db75887b21b33e54c70337611b1227ac013",
+ "assets/scss/td/_tokens-typography.scss": "505f9515a9cf687e0a2454734a1257b9b644f714da836a7384b068f112316ea5",
+ "assets/scss/td/_variables.scss": "be70e38cb9290f5a50e9c7b185c7c6d14e4d6e6aa10b5b412ddbfc5affaa65db",
+ "assets/scss/td/_variables_forward.scss": "09a2f6f9183f2512ef12f1c6f313a05945ab0cfff8952258e43e6458e1934f18",
+ "assets/scss/td/_version-selector.scss": "e04b539537065d2f911e6eb315b96798e6a48985ea913e0095478e0437eb7a66",
+ "assets/scss/td/chroma/_dark.scss": "0c8235197bf5110ca29205615c73487fb345050a5a7088c67a258b5700d83ac9",
+ "assets/scss/td/chroma/_light.scss": "88fe60d78c58bef59c4a084a64129ebb28c0e8a9cff1a16bb78fe5470e044329",
+ "assets/scss/td/shell/_blog.scss": "2d7e97ff137cf6bd852dec97e01c40fc8ce33ce7055b6c35a0bbdbdf4743b292",
+ "assets/scss/td/shell/_featured.scss": "836efc4118719e5609f2f2b872303e03f0d5c80b18618927133a307cd939bd00",
+ "assets/scss/td/shell/_kbd-nav.scss": "82d4fad6dadc9acb1ec3aeea10051a3aad8e21e0336fb0391c4abbd01ae9a4a4",
+ "assets/scss/td/shell/_layout.scss": "e0b7d660d1441daa99b7c7d935be63f8f7dcd01ad47396a6bfc826a55d0e26e4",
+ "assets/scss/td/shell/_page-context.scss": "18844eee8647491f274dfdc6ced87650a91214f6106093bc6d66eed02714e553",
+ "assets/scss/td/shell/_search.scss": "c5b6ce619ef04f6d42bb0b1aee091b5269ba38c139208181841b0df36426de7e",
+ "assets/scss/td/shell/_sidebar.scss": "7529487e05f1355dfa6259a2f4baf6869e49e1f50a917961186ea7aa0bd31edd",
+ "assets/scss/td/shell/_toc.scss": "5e8b82779d52918ab8ca8882fb039ecd97a69b501f0edb7295386930e81e9c4b",
+ "assets/scss/td/shell/_tokens.scss": "29d17a1d5bc62ec53d52b510887aa44a9bfc731049de05d6ff7e33f2932b5df3",
+ "assets/scss/td/shortcodes/_content-primitives.scss": "a2aef85daa0fa72fb4d56fa4026e92b12facccce7914c04d2c3cc4b19b9965ad",
+ "assets/scss/td/shortcodes/_steps.scss": "24735dbdb6fc40de3e931870067c02a33a57b2a818ef9c4d0da0f78d13c9be85",
+ "assets/scss/td/support/_bootstrap_vers_test.scss": "dd1830c809c6e2bf5e9c9df6954240e58fbfb70071ef4fa45494049234503949",
+ "assets/scss/td/support/_mixins.scss": "62200089bd276c0596042661ec5145dabbf583fc61705bf179f97b642dba97b9",
+ "assets/scss/td/support/_rtl.scss": "d2928aa5dd933ff2514f5d59cfa92a489625fb0d65160f5fb7e11ee60ee80146",
+ "assets/scss/td/support/_utilities.scss": "52f3f2fbb604692b776d6e84e8a71af523cff7fb22cfa02982281deca9c2f49e",
+ "i18n/en.yaml": "4f7c51d2c9da6637ef67b70e0b56e0d44ce3c18ac50ad2ff3b2c64c57eadaf3c",
+ "i18n/zh-cn.yaml": "9aee14576b61335d905f3ffd267cafb411e917be710b355bbaffb4a15896f40d",
+ "layouts/404.html": "84e60c0f985a3182558034e6331794b3954a1bdf36a12cf3c84deb03ed9e87e9",
+ "layouts/_partials/actions/manifest.html": "8d706f3c0383da9a8f9e5db5297fe41b1d10e03cf0ebccc1feb78cebd02e1584",
+ "layouts/_partials/backlinks-sources.html": "46ede594b89f971731dd5caf322bd13b0dc7b791f5e04294daa0d793bff1908b",
+ "layouts/_partials/backlinks.html": "be94ac2f7917993128f603188045b69fb425bd2882b1888ad2d3db253ce72653",
+ "layouts/_partials/content/image-zoom-config.html": "3ad22eed2f6317dab16429cd91dffa2b01ab433674409b9ac5b4be41498b6fba",
+ "layouts/_partials/content/render.html": "573629e404d2eb48dc53908c7b4540bf58c05de160f8274415482169a391411e",
+ "layouts/_partials/footer.html": "6cbf0f74e6591b3235d47ed5cdad537c795508bd4af27e789962ec41f4b03544",
+ "layouts/_partials/hooks/body-end.html": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "layouts/_partials/hooks/head-end.html": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "layouts/_partials/navbar-item.html": "5db4e341e3cf1ec568942c6ba656f656763a9f703211511fe71d7ea8d4d58dee",
+ "layouts/_partials/navbar.html": "dc1b6e858314e959d945c5180489ba2b535efe9fd3498e0e90329bf9c455c413",
+ "layouts/_partials/print/page-content.html": "fb336ffbfd696df7506b5c4248dbfd88dea63d6e16f04aca0cbf93adc00f6244",
+ "layouts/_partials/seo-alternates.html": "615043776c7cbf9b2087d648be7f2ea1009e798d696b9e366b0991ea1b3c42ff",
+ "layouts/_partials/share/bar.html": "27918149bf9916994ecea61ee5ca2fae036d8d8438ba7faad802342419268101",
+ "layouts/_partials/shell/config.html": "c7230e99b07f7d559ed371545983405a954ae69371de291b22567ade78b14560",
+ "layouts/_partials/shell/search-enabled.html": "0c55b2dfb3438942ff39eaf6b5b30829920af4871a71c88b0aa3d55c671a50d6",
+ "layouts/_partials/shell/sidebar-panel.html": "87ac2ef5b82c1328697be791c7638ee117cfe27e655712b71c51864912367ed3",
+ "layouts/_partials/shell/toc.html": "7f3bce19c87102e53bc87c1c6fca8dcf935bb72b53a1fe3cf8c1800f414622af",
+ "layouts/landing.html": "073148bdbea02e8d4e5bd3cfc61c1efdc9213de73345a6027ba242eff014d8a7"
+ }
+}
diff --git a/scripts/oink-upgrade.md b/scripts/oink-upgrade.md
new file mode 100644
index 000000000..af33f589a
--- /dev/null
+++ b/scripts/oink-upgrade.md
@@ -0,0 +1,113 @@
+# OINK upgrade SOP
+
+The theme version and its two checksums live only in `go.mod` / `go.sum`.
+The historical builder and CI resolve the same module through `oink_module.py`:
+site identity, sole theme dependency, no replacement/exclusion, resolved identity,
+and downloaded checksums are all checked. Do not run `go mod tidy`: Hugo uses
+this dependency without Go source imports.
+
+## Upstream reference
+
+The [OINK v1.1.0 release notes](https://github.com/pgsty/oink/releases/tag/v1.1.0)
+introduce the public [sidebar state API (#41)](https://github.com/pgsty/oink/issues/41)
+and [search-tail API (#40)](https://github.com/pgsty/oink/issues/40).
+HugeGraph uses these instead of its previous DOM/state workarounds. The local
+Print override follows the 1.1 plain/book return contract; generic Chinese UI
+labels come from OINK, while site-owned labels remain in `i18n/`.
+Use the tagged source when checking API details; issue proposals are background.
+
+## Normal upgrade
+
+Start from a clean worktree with the Go and Hugo versions documented in
+[contribution.md](../contribution.md), Python 3, and Node.js satisfying
+`tests/e2e/package.json`. Network access is needed for modules, archived content,
+npm and Chromium. Local ports 4173, 4174 and 4175 must be available.
+
+```sh
+scripts/update-oink.sh v1.1.0
+```
+
+Use an explicit release tag. The command updates the lock files, removes old-version
+checksums from `go.sum`, validates module
+identity, reports upstream changes affecting customizations, then reuses the
+strict build, Python/link tests, all historical builds, aggregate validation
+(which validates each version artifact once),
+blocking Chromium suite and visual captures. It installs the locked browser test
+dependencies. It does not commit, push or publish.
+
+A temporary `hugegraph-oink-*` directory printed at startup holds the review
+report and build artifacts, including on failure. Browser failures keep the
+existing Playwright report and traces. No automatic reset is performed.
+A same-version run is valid. After a failed or interrupted run, retain the target
+pin and resume explicitly; this accepts a dirty worktree for manual adaptations:
+
+```sh
+scripts/update-oink.sh v1.1.0 --resume
+```
+
+## Compatibility review and breaking changes
+
+1. Read the release notes and the printed `review.json`. Its `from` version is
+ the last reviewed baseline, not necessarily the previous command's target.
+2. Compare each reported file in the two downloaded module directories. For
+ templates, compare old upstream, new upstream, and the corresponding local
+ override. Absorb upstream changes while retaining the site behavior.
+3. Prefer documented configuration, hooks and public JavaScript APIs. Adapt the
+ relevant integration and its behavior tests together. Do not patch module
+ cache files or add a second DOM/state controller alongside the theme.
+4. After reviewing every reported change, rerun the full verification:
+
+ ```sh
+ scripts/update-oink.sh v1.1.0 --resume --accept-reviewed
+ ```
+
+ This acknowledges review; it does not bypass any tests. The baseline advances
+ only after every command succeeds. If a test fails, fix its cause and rerun.
+5. Inspect the visual captures under `tests/e2e/visual-results`, compare before
+ and after desktop/mobile in both languages, and independently review the final
+ diff before release. Automated success is not a visual approval or a promise
+ of compatibility with every untested upstream change.
+
+Get upstream directories for comparison without changing the site pin:
+
+```sh
+go mod download -json github.com/pgsty/oink@v1.0.0
+go mod download -json github.com/pgsty/oink@v1.1.0
+git diff --check
+git diff -- go.mod go.sum layouts assets scripts .github/workflows/hugo.yml
+```
+
+## Customization map
+
+| Customization | Local entry | Review evidence |
+| --- | --- | --- |
+| Version/language navigation and historical shell | `layouts/`, `scripts/versioning.py` | All-version aggregate, versioning and platform browser tests |
+| Sidebar expansion memory | `assets/js/hugegraph-shell.js` | Public OinkSidebar API, storage/focus/keyboard browser tests |
+| Authorized Ask AI | `assets/js/kapa-adapter.js` | Public search-tail API, adapter tests and AI browser contracts |
+| Brand and layout | `assets/scss/`, `data/`, `i18n/` | Bilingual desktop/mobile, dark/light and print captures |
+
+`scripts/oink-overrides.json` records upstream hashes, not copies of upstream
+code. The generated inventory covers same-name local layouts/assets/translations/data,
+the sidebar/search/surface interface files and upstream SCSS used by local CSS.
+New, removed or changed upstream files in that inventory require review. Site-only
+hooks are listed above; their behavior is checked by tests rather than inferred
+from matching filenames. Template hashes alone never prove full compatibility.
+
+Adding or removing a same-name local override changes the inventory even without
+a theme upgrade. The read-only `--check-baseline` diagnostic names added, removed,
+and changed paths. Review those changes, then run
+`scripts/update-oink.sh <pinned-version> --resume --accept-reviewed` to validate
+and record the revised inventory; use the version currently pinned in `go.mod`.
+
+For this initial 1.1 migration the baseline is generated after manual review and
+is accepted with the repository's full migration verification. Future runs update
+it only after the same automated gates pass.
+
+## Rollback
+
+Keep the upgrade in a dedicated commit. To undo an already committed upgrade,
+revert that commit (including adaptations and the baseline), then rerun the
+restored target with `--resume`. For an uncommitted attempt, inspect `git diff`
+and restore only the upgrade files from the known pre-upgrade revision; do not
+reset the whole worktree or remove unrelated changes. The script never performs
+either rollback automatically. Publishing remains the existing release workflow.
diff --git a/scripts/oink_module.py b/scripts/oink_module.py
index 6bca61db4..01040cd8e 100644
--- a/scripts/oink_module.py
+++ b/scripts/oink_module.py
@@ -10,6 +10,7 @@ import json
import os
import re
import subprocess
+import sys
from pathlib import Path
MODULE = "github.com/pgsty/oink"
@@ -17,49 +18,63 @@ 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 command(args, root, go_executable=None):
+ argv = [go_executable or os.environ.get("GO_BIN", "go"), *args]
+ try:
+ return subprocess.check_output(argv, cwd=root, text=True, stderr=subprocess.PIPE)
+ except subprocess.CalledProcessError as error:
+ detail = (error.stderr or error.output or "").strip()
+ raise ValueError(f"Go command {' '.join(argv)} failed ({error.returncode}): {detail}") from None
+ except OSError as error:
+ raise ValueError(f"Go executable is unavailable: {argv[0]} ({error})") from None
-def locked_version(root):
- data = json.loads(command(["mod", "edit", "-json"], root))
+def locked_version(root, go_executable=None):
+ data = json.loads(command(["mod", "edit", "-json"], root, go_executable))
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")
+ raise ValueError(f"expected the HugeGraph module with only OINK and no replace/exclude directives; actual: {data!r}")
version = requirements[0]["Version"]
if not VERSION.fullmatch(version):
- raise ValueError("OINK must be pinned to an explicit release version")
+ raise ValueError(f"OINK must be pinned to an explicit release version; actual: {version!r}")
return version
-def download_locked(root):
+def download_locked(root, go_executable=None):
root = Path(root)
- version = locked_version(root)
+ version = locked_version(root, go_executable)
sums = {}
for line in (root / "go.sum").read_text().splitlines():
if not line.strip():
continue
path, revision, digest = line.split()
+ if (path, revision) in sums:
+ raise ValueError(f"duplicate go.sum entry: {path} {revision}")
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))
+ raise ValueError(f"expected checksums for {MODULE}@{version} and /go.mod in go.sum; actual entries: {list(sums)!r}")
+ if set(sums) != {(MODULE, version), (MODULE, version + "/go.mod")}:
+ raise ValueError(f"go.sum must contain only {MODULE}@{version} and /go.mod; actual entries: {list(sums)!r}")
+ module = json.loads(command(["mod", "download", "-json", MODULE + "@" + version], root, go_executable))
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))
+ raise ValueError(f"expected {MODULE}@{version} checksums {expected!r}; downloaded metadata: {module!r}")
+ resolved = json.loads(command(["list", "-m", "-json", MODULE], root, go_executable))
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()
+ raise ValueError(f"expected resolved {MODULE}@{version} without replacement; actual: {resolved!r}")
+ graph = command(["list", "-m", "all"], root, go_executable).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)
+ raise ValueError(f"expected module graph {[SITE_MODULE, MODULE + ' ' + version]!r}; actual: {graph!r}")
+ command(["mod", "verify"], root, go_executable)
return module
if __name__ == "__main__":
- print(json.dumps(download_locked(Path(__file__).resolve().parents[1]), indent=2))
+ try:
+ print(json.dumps(download_locked(Path(__file__).resolve().parents[1]), indent=2))
+ except (ValueError, OSError) as error:
+ sys.exit(f"OINK module verification failed: {error}")
diff --git a/scripts/test_download_data.py b/scripts/test_download_data.py
index 5ac7123c4..fa2889f9f 100644
--- a/scripts/test_download_data.py
+++ b/scripts/test_download_data.py
@@ -63,8 +63,9 @@ EXPECTED_ARTIFACTS = {
},
}
+# Site-owned labels only; ui_assets_download is inherited from OINK and checked
+# in the rendered output below.
FIXED_I18N_KEYS = (
- "ui_assets_download",
"download_release_version",
"download_release_date",
"download_release_notes",
@@ -203,6 +204,9 @@ class DownloadDataTest(unittest.TestCase):
self.assertTrue(page.is_file(), page)
text = page.read_text(encoding="utf-8")
self.assertIn("hg-asf-release", text, page)
+ labels = re.findall(r'aria-label="([^"<>]+): apache-hugegraph[^"<>]*"', text)
+ self.assertTrue(labels, page)
+ self.assertTrue(all(label.strip() and label != "ui_assets_download" for label in labels), 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)
diff --git a/scripts/test_oink_module.py b/scripts/test_oink_module.py
index 897d4691a..dd070bf8c 100644
--- a/scripts/test_oink_module.py
+++ b/scripts/test_oink_module.py
@@ -8,6 +8,8 @@
import json
from pathlib import Path
import tempfile
+import subprocess
+import sys
import unittest
from unittest.mock import patch
@@ -15,11 +17,45 @@ import oink_module
class ModuleLockTest(unittest.TestCase):
+ def test_build_resolves_relative_go_before_changing_directory(self):
+ import argparse
+ import os
+ import versioning
+ actual_run = subprocess.run
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ executable = root / "go"
+ executable.write_text("#!/bin/sh\nprintf 'resolved-go'\n")
+ executable.chmod(0o755)
+ relative = os.path.relpath(executable, Path.cwd())
+ entry = {"id": "latest", "archived": False, "publishPath": ""}
+ args = argparse.Namespace(manifest=root / "manifest", version="latest", sha="a" * 40,
+ output=root / "out", site_origin="https://example.org/",
+ historical_origin="https://example.org/")
+
+ def download(assembly, go):
+ self.assertEqual(go, str(executable.resolve()))
+ self.assertEqual(subprocess.check_output([go], cwd=assembly, text=True), "resolved-go")
+ raise ValueError("verified relative Go from assembly")
+
+ with patch.dict(os.environ, GO_BIN=relative), \
+ patch.object(versioning, "load_manifest", return_value={"versions": [entry]}), \
+ patch.object(versioning, "prepare_output_directory", return_value=args.output), \
+ patch.object(versioning, "run"), \
+ patch.object(versioning.subprocess, "run", side_effect=lambda argv, **kw:
+ None if argv[0] == "git" else actual_run(argv, **kw)), \
+ patch.object(versioning, "overlay_shell", side_effect=lambda assembly, **kw: assembly.mkdir()), \
+ patch.object(versioning, "derived_version_config", return_value={}), \
+ patch.object(versioning, "download_locked", side_effect=download) as resolve:
+ with self.assertRaisesRegex(SystemExit, "OINK module verification failed: verified relative Go"):
+ versioning.build(args)
+ resolve.assert_called_once()
+
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):
+ def download(self, version="v1.0.0", config=None, metadata=None, resolved=None, graph=None, sums=True, extra=""):
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 {})
@@ -29,12 +65,37 @@ class ModuleLockTest(unittest.TestCase):
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 "")
+ (f"\n{oink_module.MODULE} {version} h1:archive\n\n{oink_module.MODULE} {version}/go.mod h1:mod\n" if sums else "") + extra)
with patch.object(oink_module, "command", side_effect=replies) as command:
- result = oink_module.download_locked(root)
+ result = oink_module.download_locked(root, "/verified/bin/go")
self.assertEqual(command.call_args.args[0], ["mod", "verify"])
+ self.assertTrue(all(call.args[2] == "/verified/bin/go" for call in command.call_args_list))
return result
+ def test_rejects_stale_and_duplicate_checksums(self):
+ for extra in [f"{oink_module.MODULE} v0.9.0 h1:stale\n",
+ f"{oink_module.MODULE} v1.0.0 h1:duplicate\n"]:
+ with self.subTest(extra=extra), self.assertRaises(ValueError):
+ self.download(extra=extra)
+
+ def test_mismatch_diagnostic_has_expected_and_actual(self):
+ with self.assertRaisesRegex(ValueError, "h1:archive.*h1:wrong"):
+ self.download(metadata={"Sum": "h1:wrong"})
+
+ def test_failed_go_command_preserves_download_error(self):
+ failure = subprocess.CalledProcessError(1, ["go"], output='{"Error":"checksum mismatch"}')
+ with patch.object(oink_module.subprocess, "check_output", side_effect=failure):
+ with self.assertRaisesRegex(ValueError, "verified/bin/go.*checksum mismatch"):
+ oink_module.command(["mod", "download", "-json"], Path("."), "/verified/bin/go")
+
+ def test_cli_missing_go_has_no_traceback(self):
+ import os
+ result = subprocess.run([sys.executable, oink_module.__file__], text=True, capture_output=True,
+ env=dict(os.environ, GO_BIN="/missing/oink-test-go"))
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("/missing/oink-test-go", result.stderr)
+ self.assertNotIn("Traceback", result.stderr)
+
def test_accepts_each_locked_release(self):
for version in ["v1.0.0", "v1.1.0"]:
with self.subTest(version=version):
diff --git a/scripts/test_oink_upgrade.py b/scripts/test_oink_upgrade.py
new file mode 100644
index 000000000..c0af354cb
--- /dev/null
+++ b/scripts/test_oink_upgrade.py
@@ -0,0 +1,101 @@
+#!/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 update_oink
+
+
+class UpgradeTest(unittest.TestCase):
+ def test_invalid_version_stops_before_commands(self):
+ with patch.object(update_oink.subprocess, "check_output") as command:
+ with self.assertRaises(ValueError):
+ update_oink.preflight(Path("."), "latest", False)
+ command.assert_not_called()
+
+ def test_dirty_tree_requires_explicit_resume(self):
+ with patch.object(update_oink.subprocess, "check_output", return_value=" M file"):
+ with self.assertRaisesRegex(ValueError, "not clean"):
+ update_oink.preflight(Path("."), "v1.1.0", False)
+ with patch.object(update_oink, "locked_version", return_value="v1.1.0"):
+ update_oink.preflight(Path("."), "v1.1.0", True)
+ with self.assertRaises(ValueError):
+ update_oink.preflight(Path("."), "v1.2.0", True)
+
+ def test_locale_override_uses_upstream_canonical_case(self):
+ with tempfile.TemporaryDirectory() as d:
+ root, upstream = Path(d) / "site", Path(d) / "theme"
+ (root / "i18n").mkdir(parents=True)
+ (upstream / "i18n").mkdir(parents=True)
+ (root / "i18n/zh-CN.yaml").write_text("local translation")
+ (upstream / "i18n/zh-cn.yaml").write_text("upstream translation")
+ files = update_oink.snapshot(root, upstream)
+ self.assertIn("i18n/zh-cn.yaml", files)
+ self.assertNotIn("i18n/zh-CN.yaml", files)
+ self.assertIsNotNone(files["i18n/zh-cn.yaml"])
+
+ def test_review_catches_added_changed_deleted_files(self):
+ self.assertEqual(update_oink.changes({"old": "a", "changed": "b", "stable": "c"},
+ {"new": "d", "changed": "e", "stable": "c"}),
+ ["changed", "new", "old"])
+ self.assertEqual(update_oink.changes({"a": "b"}, {"a": "b"}), [])
+
+ def test_failed_validation_keeps_baseline(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = Path(d)
+ baseline = root / "baseline.json"
+ original = json.dumps({"version": "v1.0.0", "files": {"a": "old"}})
+ baseline.write_text(original)
+ with patch.object(update_oink, "BASELINE", baseline), patch.object(update_oink, "preflight"), patch.object(update_oink, "prune_checksums"), \
+ patch.object(update_oink, "download_locked", return_value={"Dir": d}), \
+ patch.object(update_oink, "snapshot", return_value={"a": "new"}), \
+ patch.object(update_oink.tempfile, "mkdtemp", return_value=d), \
+ patch.object(update_oink, "validate", side_effect=ValueError("test failed")), \
+ patch("sys.argv", ["update", "v1.1.0", "--resume", "--accept-reviewed"]):
+ with self.assertRaisesRegex(ValueError, "test failed"):
+ update_oink.main()
+ self.assertEqual(baseline.read_text(), original)
+
+ def test_same_target_retry_runs_gates_and_updates_baseline(self):
+ with tempfile.TemporaryDirectory() as d:
+ baseline = Path(d) / "baseline.json"
+ baseline.write_text(json.dumps({"version": "v1.1.0", "files": {"a": "same"}}))
+ with patch.object(update_oink, "BASELINE", baseline), patch.object(update_oink, "preflight"), patch.object(update_oink, "prune_checksums"), \
+ patch.object(update_oink, "locked_version", return_value="v1.1.0"), \
+ patch.object(update_oink, "command") as command, \
+ patch.object(update_oink, "download_locked", return_value={"Dir": d}), \
+ patch.object(update_oink, "snapshot", return_value={"a": "same"}), \
+ patch.object(update_oink.tempfile, "mkdtemp", return_value=d), \
+ patch.object(update_oink, "validate") as validate, \
+ patch("sys.argv", ["update", "v1.1.0"]):
+ update_oink.main()
+ update_oink.main()
+ command.assert_not_called()
+ self.assertEqual(validate.call_count, 2)
+ self.assertEqual(json.loads(baseline.read_text())["version"], "v1.1.0")
+
+ def test_unreviewed_change_stops_before_validation(self):
+ with tempfile.TemporaryDirectory() as d:
+ baseline = Path(d) / "baseline.json"
+ baseline.write_text(json.dumps({"version": "v1.0.0", "files": {"a": "old"}}))
+ with patch.object(update_oink, "BASELINE", baseline), patch.object(update_oink, "preflight"), patch.object(update_oink, "prune_checksums"), \
+ patch.object(update_oink, "download_locked", return_value={"Dir": d}), \
+ patch.object(update_oink, "snapshot", return_value={"a": "new"}), \
+ patch.object(update_oink.tempfile, "mkdtemp", return_value=d), \
+ patch.object(update_oink, "validate") as validate, \
+ patch("sys.argv", ["update", "v1.1.0", "--resume"]):
+ with self.assertRaisesRegex(ValueError, "review required"):
+ update_oink.main()
+ validate.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_update_oink.py b/scripts/test_update_oink.py
new file mode 100644
index 000000000..91858715f
--- /dev/null
+++ b/scripts/test_update_oink.py
@@ -0,0 +1,99 @@
+#!/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.
+
+from pathlib import Path
+import tempfile
+import unittest
+import json
+from unittest.mock import patch
+
+import update_oink
+
+from oink_module import MODULE
+from update_oink import describe_changes, prune_checksums, snapshot
+
+
+class UpgradeInventoryTest(unittest.TestCase):
+ def test_validation_builds_both_ai_modes_before_browser_tests(self):
+ with tempfile.TemporaryDirectory() as directory:
+ work = Path(directory)
+ (work / "resolved.json").write_text(json.dumps({"versions": []}))
+ with patch.object(update_oink, "run") as run:
+ update_oink.validate(work)
+ calls = run.call_args_list
+ browser = next(call for call in calls if call.args[0] == ["npm", "run", "test:ci"])
+ for name, config, variable in [("ai", "ai-enabled", "AI_SITE_ROOT"),
+ ("ai-disabled", "ai-disabled", "AI_DISABLED_SITE_ROOT")]:
+ self.assertEqual(browser.kwargs["env"][variable], str(work / name))
+ builds = [call for call in calls if "--destination" in call.args[0]
+ and call.args[0][call.args[0].index("--destination") + 1] == work / name]
+ self.assertEqual(len(builds), 1)
+ self.assertIn(f"tests/e2e/{config}.yaml", str(builds[0].args[0]))
+ self.assertLess(calls.index(builds[0]), calls.index(browser))
+
+ def test_resume_cleans_interrupted_upgrade_before_validation(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ target = f"{MODULE} v1.1.0 h1:new\n{MODULE} v1.1.0/go.mod h1:newmod\n"
+ (root / "go.sum").write_text(f"{MODULE} v1.0.0 h1:old\n" + target)
+ baseline = root / "baseline.json"
+ baseline.write_text(json.dumps({"version": "v1.1.0", "files": {}}))
+
+ def download(path):
+ self.assertEqual((path / "go.sum").read_text(), target)
+ return {"Dir": str(root)}
+
+ with patch.object(update_oink, "ROOT", root), patch.object(update_oink, "BASELINE", baseline), \
+ patch.object(update_oink, "locked_version", return_value="v1.1.0"), \
+ patch.object(update_oink, "download_locked", side_effect=download), \
+ patch.object(update_oink, "snapshot", return_value={}), \
+ patch.object(update_oink, "validate") as validate, \
+ patch.object(update_oink, "command") as command, \
+ patch.object(update_oink.tempfile, "mkdtemp", return_value=directory), \
+ patch("sys.argv", ["update", "v1.1.0", "--resume"]):
+ update_oink.main()
+ update_oink.main()
+ command.assert_not_called()
+ self.assertEqual(validate.call_count, 2)
+
+ def test_data_override_and_locale_inventory(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root, upstream = Path(directory) / "site", Path(directory) / "theme"
+ for base, paths in [(root, ["data/brand.yaml", "data/site-only.yaml", "i18n/zh_CN.yaml"]),
+ (upstream, ["data/brand.yaml", "data/upstream-only.yaml", "i18n/zh-cn.yaml"])]:
+ for name in paths:
+ path = base / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("value")
+ result = snapshot(root, upstream)
+ self.assertIn("data/brand.yaml", result)
+ self.assertIn("i18n/zh-cn.yaml", result)
+ self.assertNotIn("data/site-only.yaml", result)
+ self.assertNotIn("data/upstream-only.yaml", result)
+ before = dict(result)
+ (root / "data/brand.yaml").unlink()
+ self.assertIn("removed: data/brand.yaml", describe_changes(before, snapshot(root, upstream)))
+
+ def test_change_diagnostics(self):
+ self.assertEqual(describe_changes({"b": "old", "c": "old"}, {"a": "new", "b": "new"}),
+ ["added: a", "changed: b", "removed: c"])
+
+ def test_upgrade_removes_old_checksums(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ old = f"{MODULE} v1.0.0 h1:old\n{MODULE} v1.0.0/go.mod h1:oldmod\n"
+ new = f"{MODULE} v1.1.0 h1:new\n{MODULE} v1.1.0/go.mod h1:newmod\n"
+ (root / "go.sum").write_text(old + new)
+ prune_checksums(root, "v1.1.0")
+ self.assertEqual((root / "go.sum").read_text(), new)
+ with self.assertRaises(ValueError):
+ prune_checksums(root, "v9.0.0")
+ self.assertEqual((root / "go.sum").read_text(), new)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_versioning.py b/scripts/test_versioning.py
index f39e76881..99cb51380 100644
--- a/scripts/test_versioning.py
+++ b/scripts/test_versioning.py
@@ -686,9 +686,10 @@ class VersionUrlTest(unittest.TestCase):
seen: set[str] = set()
self.assertEqual(versioning.write_error_documents(output, seen), 4)
self.assertEqual(
- (output / ".htaccess").read_text(encoding="utf-8"),
+ "\n".join(line for line in (output / ".htaccess").read_text(encoding="utf-8").splitlines() if not line.startswith("#")) + "\n",
'RedirectMatch 404 "(?i)(?:^|/)\\.git(?:/|$)"\n'
- "ErrorDocument 404 /404.html\n",
+ "ErrorDocument 404 /404.html\n"
+ '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"\n',
)
self.assertEqual(
(output / "cn/.htaccess").read_text(encoding="utf-8"),
@@ -1780,11 +1781,41 @@ class VersionUrlTest(unittest.TestCase):
with self.assertRaises(SystemExit):
versioning.aggregate(args)
+ def test_aggregate_flattened_artifact_checks_identity_and_selection(self) -> None:
+ for changed_field, select in [(None, "latest"), ("id", "latest"),
+ ("sha", "latest"), (None, None)]:
+ with self.subTest(field=changed_field, select=select), tempfile.TemporaryDirectory() as directory:
+ temp = Path(directory)
+ manifest = json.loads((versioning.ROOT / "versions.json").read_text())
+ for entry in manifest["versions"]:
+ entry["sha"] = "a" * 40
+ resolved = temp / "resolved.json"
+ resolved.write_text(json.dumps(manifest))
+ artifacts = temp / "artifacts"
+ artifacts.mkdir()
+ metadata = dict(manifest["versions"][0])
+ if changed_field:
+ metadata[changed_field] = "wrong" if changed_field == "id" else "b" * 40
+ (artifacts / ".version.json").write_text(json.dumps(metadata))
+ args = argparse.Namespace(resolved_manifest=resolved, artifacts=artifacts,
+ artifact_prefix="staging-", artifact_suffix="-123",
+ select=select, site_origin=ORIGIN,
+ output=temp / "aggregate", asf_profile=None, asf_whoami=None)
+ with mock.patch.object(versioning, "validate_artifact", side_effect=RuntimeError("validated source")) as validate:
+ if changed_field or select is None:
+ with self.assertRaises(SystemExit):
+ versioning.aggregate(args)
+ validate.assert_not_called()
+ else:
+ with self.assertRaisesRegex(RuntimeError, "validated source"):
+ versioning.aggregate(args)
+ self.assertEqual(validate.call_args.args[0].artifact, artifacts)
+
def test_aggregate_security_scan_runs_after_metadata_is_written(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
temp = Path(temp_name)
output = temp / "aggregate"
- source = temp / "artifacts/latest"
+ source = temp / "artifacts"
source.mkdir(parents=True)
entry = {"id": "latest", "publishPath": "", "sha": "a" * 40}
(source / ".version.json").write_text(json.dumps(entry), encoding="utf-8")
diff --git a/scripts/update-oink.sh b/scripts/update-oink.sh
new file mode 100755
index 000000000..26a1f2fdb
--- /dev/null
+++ b/scripts/update-oink.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# 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.
+# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0
+set -eu
+cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
+exec "${PYTHON_BIN:-python3}" scripts/update_oink.py "$@"
diff --git a/scripts/update_oink.py b/scripts/update_oink.py
new file mode 100644
index 000000000..9986e56b1
--- /dev/null
+++ b/scripts/update_oink.py
@@ -0,0 +1,165 @@
+#!/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.
+
+"""Explicit, review-gated OINK upgrades using the existing site validators."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+from oink_module import MODULE, VERSION, command, download_locked, locked_version
+
+ROOT = Path(__file__).resolve().parents[1]
+BASELINE = ROOT / "scripts/oink-overrides.json"
+WATCH = ("assets/js/sidebar-state.js", "assets/js/sidebar-nav.js",
+ "assets/js/docs-shell.js", "assets/js/search.js", "assets/js/search-engine.js",
+ "assets/js/surface-coordinator.js", "assets/js/command-palette.js")
+
+
+def run(args, *, cwd=ROOT, env=None):
+ print("+ " + " ".join(map(str, args)), flush=True)
+ subprocess.run(list(map(str, args)), cwd=cwd, env=env, check=True)
+
+
+def snapshot(root, upstream):
+ paths = set(WATCH)
+ # Project SCSS depends on upstream selectors even without a same-name override.
+ paths.update(str(p.relative_to(upstream)) for p in (upstream / "assets/scss").rglob("*.scss"))
+ for folder in ("layouts", "assets", "i18n", "data"):
+ def key(relative):
+ name = relative.as_posix()
+ # Locale filenames are case-insensitive; record the upstream spelling
+ # rather than letting the host filesystem decide which file matches.
+ return name.casefold().replace("_", "-") if folder == "i18n" else name
+
+ upstream_paths = {key(p.relative_to(upstream)): p.relative_to(upstream).as_posix()
+ for p in (upstream / folder).rglob("*") if p.is_file()}
+ paths.update(upstream_paths[key(p.relative_to(root))]
+ for p in (root / folder).rglob("*")
+ if p.is_file() and key(p.relative_to(root)) in upstream_paths)
+ return {p: hashlib.sha256((upstream / p).read_bytes()).hexdigest()
+ if (upstream / p).is_file() else None for p in sorted(paths)}
+
+
+def changes(previous, current):
+ return [p for p in sorted(previous.keys() | current.keys()) if previous.get(p) != current.get(p)]
+
+
+def describe_changes(previous, current):
+ return [f"{'added' if p not in previous else 'removed' if p not in current else 'changed'}: {p}"
+ for p in changes(previous, current)]
+
+
+def prune_checksums(root, version):
+ """Keep only target checksums after go get; do not use go mod tidy."""
+ path = root / "go.sum"
+ lines = [line for line in path.read_text().splitlines()
+ if line.split()[:2] in [[MODULE, version], [MODULE, version + "/go.mod"]]]
+ if len(lines) != 2 or len({line.split()[1] for line in lines}) != 2:
+ raise ValueError(f"expected both target checksums before cleanup: {MODULE}@{version}")
+ path.write_text("\n".join(lines) + "\n")
+
+
+def preflight(root, target, resume):
+ if not VERSION.fullmatch(target):
+ raise ValueError("use an explicit release such as v1.1.0; branches and latest are not accepted")
+ if resume:
+ if locked_version(root) != target:
+ raise ValueError("--resume only validates the already-pinned target; it cannot switch versions")
+ elif subprocess.check_output(["git", "status", "--porcelain"], cwd=root, text=True).strip():
+ raise ValueError("working tree is not clean; commit/stash your work first, or use --resume for this pinned target")
+
+
+def validate(work):
+ py = sys.executable
+ cli = [py, "scripts/versioning.py"]
+ origin = "https://hugegraph.apache.org/"
+ historical = "https://hugegraph.apache.org/"
+ run(["bash", "dist/validate-links.sh"])
+ run([py, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py"])
+ run(["scripts/hugo.sh", "build", "--destination", work / "strict"])
+ resolved = work / "resolved.json"
+ run([*cli, "prepare", "--output", resolved])
+ manifest = json.loads(resolved.read_text())
+ for entry in manifest["versions"]:
+ artifact = work / "artifacts" / entry["id"]
+ run([*cli, "build", "--version", entry["id"], "--sha", entry["sha"],
+ "--site-origin", origin, "--historical-origin", historical, "--output", artifact])
+ # aggregate validates every version artifact before merging it.
+ run([*cli, "aggregate", "--resolved-manifest", resolved,
+ "--artifacts", work / "artifacts", "--site-origin", origin,
+ "--historical-origin", historical, "--output", work / "site"])
+ run([py, "-m", "unittest", "scripts.test_download_data.DownloadDataTest.test_rendered_download_pages_have_verified_rows", "-v"],
+ env=dict(os.environ, DOWNLOAD_PUBLIC_DIR=str(work / "site")))
+ for name, port, config in [("ai", 4174, "ai-enabled"), ("ai-disabled", 4175, "ai-disabled")]:
+ run([*cli, "config", "--version", "latest", "--site-origin", f"http://127.0.0.1:{port}/",
+ "--historical-origin", historical, "--output", work / f"{name}.json"])
+ run([os.environ.get("HUGO_BIN", "hugo"), "--config",
+ f"hugo.yaml,{work / f'{name}.json'},tests/e2e/{config}.yaml",
+ "--destination", work / name, "--cleanDestinationDir", "--gc", "--minify",
+ "--environment", "production", "--panicOnWarning"])
+ env = dict(os.environ, SITE_ROOT=str(work / "site"), AI_SITE_ROOT=str(work / "ai"),
+ AI_DISABLED_SITE_ROOT=str(work / "ai-disabled"))
+ run(["npm", "ci"], cwd=ROOT / "tests/e2e")
+ run(["npx", "playwright", "install", "chromium"], cwd=ROOT / "tests/e2e")
+ run(["npm", "run", "test:ci"], cwd=ROOT / "tests/e2e", env=env)
+ run(["npm", "run", "test:visual"], cwd=ROOT / "tests/e2e", env=env)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("version", nargs="?")
+ parser.add_argument("--check-baseline", action="store_true", help="read-only CI check of reviewed upstream files")
+ parser.add_argument("--resume", action="store_true", help="validate/adapt the already-pinned target in a dirty worktree")
+ parser.add_argument("--accept-reviewed", action="store_true", help="record human review of reported upstream changes; still run every validator")
+ args = parser.parse_args()
+ if args.check_baseline:
+ module = download_locked(ROOT)
+ baseline = json.loads(BASELINE.read_text())
+ changed = describe_changes(baseline["files"], snapshot(ROOT, Path(module["Dir"])))
+ if baseline["version"] != module["Version"] or changed:
+ detail = "; ".join(changed) or "no file changes"
+ raise ValueError(f"OINK baseline differs (reviewed {baseline['version']}, pinned {module['Version']}): "
+ f"{detail}; local override additions/removals also require review; "
+ "follow scripts/oink-upgrade.md")
+ print("Reviewed OINK upstream baseline matches")
+ return
+ if args.version is None:
+ parser.error("version is required for upgrades")
+ preflight(ROOT, args.version, args.resume)
+ previous = json.loads(BASELINE.read_text())
+ work = Path(tempfile.mkdtemp(prefix="hugegraph-oink-"))
+ print(f"Upgrade artifacts and review report: {work}", flush=True)
+ if not args.resume and locked_version(ROOT) != args.version:
+ command(["get", MODULE + "@" + args.version], ROOT)
+ prune_checksums(ROOT, args.version)
+ elif args.resume:
+ prune_checksums(ROOT, args.version)
+ module = download_locked(ROOT)
+ current = snapshot(ROOT, Path(module["Dir"]))
+ changed = changes(previous["files"], current)
+ report = {"from": previous["version"], "to": args.version, "reviewRequired": changed,
+ "scope": "same-name layouts/assets/translations/data overrides, sidebar/search interfaces and upstream SCSS; not a full compatibility proof"}
+ (work / "review.json").write_text(json.dumps(report, indent=2) + "\n")
+ if changed and not args.accept_reviewed:
+ raise ValueError(f"review required for {len(changed)} upstream files; inspect {work / 'review.json'}, adapt, then rerun with --resume --accept-reviewed")
+ validate(work)
+ BASELINE.write_text(json.dumps({"version": args.version, "files": current}, indent=2) + "\n")
+ print(f"Upgrade checks passed: {args.version}. Review visual captures in tests/e2e/visual-results before publishing. No commit or publication performed.")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (ValueError, subprocess.CalledProcessError) as error:
+ print(f"OINK check failed: {error}" if "--check-baseline" in sys.argv else
+ f"OINK upgrade incomplete: {error}. Changes and artifacts are retained.", file=sys.stderr)
+ sys.exit(1)
diff --git a/scripts/versioning.py b/scripts/versioning.py
index e9b48cc24..a891059bb 100644
--- a/scripts/versioning.py
+++ b/scripts/versioning.py
@@ -4070,10 +4070,15 @@ def build(args: argparse.Namespace) -> None:
json.dumps(override, ensure_ascii=False), encoding="utf-8"
)
hugo = os.environ.get("HUGO_BIN", "hugo")
- go_executable = shutil.which(os.environ.get("GO_BIN", "go"))
+ go = os.environ.get("GO_BIN", "go")
+ go_executable = shutil.which(go)
if go_executable is None:
- fail("Go executable is unavailable")
- module = download_locked(assembly)
+ fail(f"Go executable is unavailable: {go}")
+ go_executable = str(pathlib.Path(go_executable).resolve())
+ try:
+ module = download_locked(assembly, go_executable)
+ except (ValueError, OSError, subprocess.CalledProcessError) as error:
+ fail(f"OINK module verification failed: {error}")
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}")
@@ -4254,8 +4259,10 @@ def copy_without_collision(
def write_error_documents(output: pathlib.Path, seen: set[str]) -> int:
"""Install localized Apache error documents beside every generated 404 page."""
template = (ROOT / ".htaccess").read_text(encoding="utf-8")
- if template != (
+ directives = "\n".join(line for line in template.splitlines() if not line.startswith("#")) + "\n"
+ if directives != (
'RedirectMatch 404 "(?i)(?:^|/)\\.git(?:/|$)"\nErrorDocument 404 /404.html\n'
+ '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"\n'
):
fail("unexpected root .htaccess contract")
root_page = output / "404.html"
@@ -4361,6 +4368,10 @@ def aggregate(args: argparse.Namespace) -> None:
f"{args.artifact_prefix}{entry['id']}"
f"{getattr(args, 'artifact_suffix', '')}"
)
+ # download-artifact flattens a single match into its destination.
+ # Never use that layout to satisfy a multi-version selection.
+ if not source.exists() and len(selected) == 1:
+ source = args.artifacts
metadata_path = source / ".version.json"
if not metadata_path.is_file():
fail(f"missing version metadata: {metadata_path}")
diff --git a/tests/e2e/ai-disabled.yaml b/tests/e2e/ai-disabled.yaml
new file mode 100644
index 000000000..4586d98b5
--- /dev/null
+++ b/tests/e2e/ai-disabled.yaml
@@ -0,0 +1,3 @@
+params:
+ ai_search:
+ enabled: false
diff --git a/tests/e2e/ai.spec.js b/tests/e2e/ai.spec.js
index ac25ac29d..754f53e08 100644
--- a/tests/e2e/ai.spec.js
+++ b/tests/e2e/ai.spec.js
@@ -37,17 +37,15 @@ for (const [locale, route, source, language] of [
await page.locator("[data-td-shell-search-open]").first().click();
const input = page.locator(".td-shell-search__input");
await input.fill(" server auth ");
- const tail = page.locator("[data-hg-ai-search-tail]");
+ const tail = page.getByRole("option").filter({ hasText: /Ask AI|询问 AI|问 AI/ });
await expect(tail).toBeVisible();
- await expect(tail.locator("[data-hg-ask-ai]")).toHaveAttribute(
- "data-hg-ai-query", "server auth"
- );
+ await expect(tail).toContainText("server auth");
await input.fill("");
await expect(tail).toHaveCount(0);
await input.fill(">theme");
await expect(tail).toHaveCount(0);
await input.fill("server auth");
- await tail.locator("[data-hg-ask-ai]").click();
+ await tail.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();
@@ -165,3 +163,64 @@ test("AI pending timeout discards stale state and retry waits for a fresh bundle
)
).toBe(1);
});
+
+
+test("native AI row supports keyboard selection, IME, and consent focus handoff", 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/");
+ await page.locator("[data-td-shell-search-open]").first().click();
+ const input = page.locator(".td-shell-search__input");
+ await input.fill("server");
+ const tail = page.getByRole("option").filter({ hasText: "Ask AI:" });
+ await expect(tail).toBeVisible();
+ await input.press("Control+End");
+ await expect(tail).toHaveAttribute("aria-selected", "true");
+ await input.dispatchEvent("keydown", { key: "Enter", isComposing: true });
+ await expect(page.locator("[data-hg-ai-consent]")).toBeHidden();
+ await input.press("Enter");
+ await expect(page.locator("[data-hg-ai-consent]")).toBeVisible();
+ await expect(page.locator("#td-shell-search")).toBeHidden();
+ await page.locator("[data-hg-ai-cancel]").click();
+ await expect(page.locator(".hg-ask-ai-launcher")).toBeFocused();
+ expect(requests).toEqual([]);
+
+ await page.locator("[data-td-shell-search-open]").first().click();
+ await input.fill("zzzxqnonexistentzzzxq");
+ await expect(tail).toBeVisible();
+ await expect(tail).toHaveAttribute("aria-selected", "true");
+ await input.press("Enter");
+ await page.locator("[data-hg-ai-continue]").click();
+ await expect.poll(() => page.evaluate(() => window.__kapaCalls || [])).toContainEqual([
+ "open", { mode: "ai", query: "zzzxqnonexistentzzzxq", submit: true }
+ ]);
+});
+
+test("reopening native search cancels a pending AI handoff", async ({ page }) => {
+ let release;
+ const gate = new Promise((resolve) => { release = resolve; });
+ let requested = false;
+ await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => {
+ requested = true;
+ await gate;
+ await route.fulfill({ status: 200, contentType: "text/javascript", body: mockBundle });
+ });
+ await page.goto(AI_ORIGIN + "/docs/");
+ await page.locator("[data-td-shell-search-open]").first().click();
+ const input = page.locator(".td-shell-search__input");
+ await input.fill("zzzxqnonexistentzzzxq");
+ await expect(page.getByRole("option").filter({ hasText: "Ask AI:" })).toBeVisible();
+ await input.press("Enter");
+ await page.locator("[data-hg-ai-continue]").click();
+ await expect.poll(() => requested).toBe(true);
+ await page.locator("[data-td-shell-search-open]").first().click();
+ await expect(input).toBeFocused();
+ release();
+ await expect(page.locator(".hg-ask-ai-launcher")).toHaveAttribute("data-hg-ai-state", "idle");
+ await page.waitForTimeout(250);
+ expect(await page.evaluate(() => (window.__kapaCalls || []).filter(([method]) => method === "open"))).toEqual([]);
+ await expect(input).toBeFocused();
+});
diff --git a/tests/e2e/package.json b/tests/e2e/package.json
index 36ef845b8..37e849e98 100644
--- a/tests/e2e/package.json
+++ b/tests/e2e/package.json
@@ -6,7 +6,7 @@
},
"scripts": {
"test": "playwright test",
- "test:ci": "node --test ../ui-ai/*.test.cjs && node --test workflow-contract.test.cjs && playwright test versioning.spec.js search-ranking.spec.js platform.spec.js ai.spec.js accessibility.spec.js --reporter=line,html",
+ "test:ci": "node --test ../ui-ai/*.test.cjs && node --test workflow-contract.test.cjs && playwright test versioning.spec.js search-ranking.spec.js platform.spec.js theme-upgrade.spec.js ai.spec.js accessibility.spec.js --reporter=line,html",
"test:visual": "playwright test visual.spec.js --reporter=line"
},
"devDependencies": {
diff --git a/tests/e2e/platform.spec.js b/tests/e2e/platform.spec.js
index e7945d603..15e36fc81 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.v2.latest.${locale}`;
+ const key = `oink.sidebar.v3.latest.${locale}`;
await expect.poll(() => page.evaluate((name) => localStorage.getItem(name), key))
.not.toBeNull();
const toggle = page
@@ -21,28 +21,28 @@ for (const locale of ["en", "cn"]) {
);
await page.locator(".td-shell-sidebar__collapse").click();
- await expect(page.locator("#td-shell-sidebar")).toHaveAttribute("aria-hidden", "true");
- await expect(page.locator("#td-shell-sidebar")).toHaveJSProperty("inert", true);
- const restore = page.locator(".hg-sidebar-restore");
+ await expect(page.locator(".td-shell-sidebar__panel")).toHaveAttribute("aria-hidden", "true");
+ await expect(page.locator(".td-shell-sidebar__panel > *").first()).toHaveJSProperty("inert", true);
+ const restore = page.locator(".td-shell-float [data-td-shell-sidebar-toggle]");
await expect(restore).toBeVisible();
- const edge = page.locator(".hg-sidebar-edge");
- const panel = page.locator(".td-shell-sidebar__panel");
+ await expect(restore).toBeFocused();
await page.waitForTimeout(200);
- await edge.dispatchEvent("pointerenter", { pointerType: "mouse" });
+ await page.mouse.move(2, 200);
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 page.mouse.move(900, 300);
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(
+ await restore.focus();
+ await restore.press("Enter");
+ await expect(page.locator(".td-shell-sidebar__collapse")).toBeFocused();
+ await expect(page.locator(".td-shell-sidebar__panel")).not.toHaveAttribute(
"aria-hidden", "true"
);
});
@@ -54,7 +54,7 @@ for (const locale of ["en", "cn"]) {
await opener.click();
await expect(page.locator("html")).toHaveAttribute("data-td-shell-drawer", "open");
await page.locator("button[data-td-shell-drawer-close]").click();
- await expect(page.locator("#td-shell-sidebar")).toHaveJSProperty("inert", true);
+ await expect(page.locator(".td-shell-sidebar__panel > *").first()).toHaveJSProperty("inert", true);
await expect(opener).toBeFocused();
await expect(page.locator("html")).not.toHaveAttribute("data-td-shell-lock", "");
});
@@ -63,7 +63,7 @@ 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}`;
+ const key = `oink.sidebar.v3.latest.${locale}`;
await page.goto(`${prefix}/docs/`);
await page.evaluate((name) => localStorage.removeItem(name), key);
await page.reload();
@@ -86,17 +86,46 @@ for (const locale of ["en", "cn"]) {
});
}
-test("disabled AI emits no UI or Kapa request", async ({ page }) => {
+for (const route of ["/docs/", "/cn/docs/"]) {
+ test(`disabled AI emits no UI or third-party request at ${route}`, async ({ page }) => {
+ expect(process.env.AI_DISABLED_SITE_ROOT, "Build the AI-disabled fixture").toBeTruthy();
+ const requests = [];
+ page.on("request", (request) => {
+ if (/kapa\.ai|hcaptcha\.com|kapa-widget-proxy/.test(request.url())) {
+ requests.push(request.url());
+ }
+ });
+ await page.goto("http://127.0.0.1:4175" + route);
+ await page.locator("[data-td-shell-search-open]").first().click();
+ await page.locator(".td-shell-search__input").fill("server");
+ await expect(page.locator('[role="option"]').first()).toBeVisible();
+ await expect(page.locator("[data-hg-ask-ai]")).toHaveCount(0);
+ await expect(page.locator('script[src*="kapa-adapter"]')).toHaveCount(0);
+ expect(requests).toEqual([]);
+ });
+}
+
+test("enabled AI makes no third-party request before consent", async ({ page }) => {
const kapaRequests = [];
page.on("request", (request) => {
- if (request.url().includes("kapa.ai")) kapaRequests.push(request.url());
+ if (/kapa\.ai|hcaptcha\.com|kapa-widget-proxy/.test(request.url())) {
+ kapaRequests.push(request.url());
+ }
});
await page.goto("/docs/");
await page.locator("[data-td-shell-search-open]").first().click();
await page.locator(".td-shell-search__input").fill("server");
await expect(page.locator('[role="option"]').first()).toBeVisible();
expect(kapaRequests).toEqual([]);
- await expect(page.locator("[data-hg-ask-ai]")).toHaveCount(0);
+ await page.locator(".td-shell-search__input").press("Escape");
+ const launcher = page.locator("[data-hg-ask-ai]").first();
+ await expect(launcher).toBeVisible();
+ await launcher.click();
+ await expect(page.locator("[data-hg-ai-consent]")).toBeVisible();
+ await page.locator("[data-hg-ai-cancel]").click();
+ await expect(page.locator("[data-hg-ai-consent]")).not.toBeVisible();
+ await expect(launcher).toBeFocused();
+ expect(kapaRequests).toEqual([]);
});
test("search index failure keeps one stable, focusable retry control", async ({
diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js
index 7eef0aceb..9a6103014 100644
--- a/tests/e2e/playwright.config.js
+++ b/tests/e2e/playwright.config.js
@@ -2,6 +2,7 @@ const { defineConfig } = require("@playwright/test");
const siteRoot = process.env.SITE_ROOT;
const aiSiteRoot = process.env.AI_SITE_ROOT;
+const aiDisabledSiteRoot = process.env.AI_DISABLED_SITE_ROOT;
if (!siteRoot) {
throw new Error("SITE_ROOT must point to an aggregate site artifact");
}
@@ -39,6 +40,16 @@ module.exports = defineConfig({
stdout: "ignore",
stderr: "ignore"
}]
+ : []),
+ ...(aiDisabledSiteRoot
+ ? [{
+ command: `python3 -m http.server 4175 --bind 127.0.0.1 --directory ${JSON.stringify(aiDisabledSiteRoot)}`,
+ url: "http://127.0.0.1:4175/",
+ reuseExistingServer: false,
+ timeout: 30_000,
+ stdout: "ignore",
+ stderr: "ignore"
+ }]
: [])
]
});
diff --git a/tests/e2e/search-ranking.spec.js b/tests/e2e/search-ranking.spec.js
index 06f79a4a6..1401c2c58 100644
--- a/tests/e2e/search-ranking.spec.js
+++ b/tests/e2e/search-ranking.spec.js
@@ -65,9 +65,9 @@ const cases = Object.fromEntries(
);
for (const [locale, localeCases] of Object.entries(cases)) {
- test(`summary Lunr ranks fixed ${locale} entry queries`, async ({ page }) => {
- test.skip(!metadataIntegrated, "PR-B search metadata fixture is not integrated");
- for (const [query, expectedRef, expectedTitle] of localeCases) {
+ for (const [query, expectedRef, expectedTitle] of localeCases) {
+ test(`summary Lunr ranks ${locale}: ${query}`, async ({ page }) => {
+ test.skip(!metadataIntegrated, "Search metadata fixture is unavailable");
await page.goto(locale === "cn" ? "/cn/docs/" : "/docs/");
await page.locator("[data-td-shell-search-open]").first().click();
const input = page.locator(".td-shell-search__input");
@@ -98,6 +98,6 @@ for (const [locale, localeCases] of Object.entries(cases)) {
const target = pageResults.filter({ hasText: expectedTitle }).first();
await target.click();
await expect(page).toHaveURL((url) => url.pathname === expectedRef);
- }
- });
+ });
+ }
}
diff --git a/tests/e2e/theme-upgrade.spec.js b/tests/e2e/theme-upgrade.spec.js
new file mode 100644
index 000000000..846d80037
--- /dev/null
+++ b/tests/e2e/theme-upgrade.spec.js
@@ -0,0 +1,95 @@
+const { test, expect } = require('./artifact-test');
+
+const expectedVersions = (process.env.EXPECTED_VERSIONS || 'latest,1.7,1.5,1.3,1.0').split(',');
+
+for (const prefix of ['', '/cn', '/versions/1.7', '/versions/1.7/cn']) {
+ test(`print preserves authored content after theme upgrade: ${prefix || 'en'}`, async ({ page }) => {
+ const versionId = prefix.startsWith('/versions/1.7') ? '1.7' : 'latest';
+ test.skip(!expectedVersions.includes(versionId), 'version not selected for this artifact');
+ await page.goto(`${prefix}/docs/introduction/`);
+ const excerpt = (await page.locator('.td-content p').first().innerText()).trim();
+ expect(excerpt.length).toBeGreaterThan(10);
+ // Print URLs put the output prefix inside the selected version's base path.
+ const version = prefix.startsWith('/versions/1.7') ? '/versions/1.7' : '';
+ const language = prefix.endsWith('/cn') ? '/cn' : '';
+ const response = await page.goto(`${version}${language}/_print/docs/`);
+ expect(response.status()).toBe(200);
+ await expect(page.locator('body')).toContainText(excerpt);
+ expect(await page.locator('template[data-hg-authored-content="start"]').count()).toBeGreaterThan(0);
+ await expect(page.locator('body')).not.toContainText('map[book:');
+ });
+}
+
+test.describe('without JavaScript', () => {
+ test.use({ javaScriptEnabled: false });
+ test('server-rendered documentation remains navigable', async ({ page }) => {
+ await page.goto('/cn/docs/');
+ const sidebar = page.locator('#td-shell-sidebar');
+ await expect(sidebar).toBeVisible();
+ await expect(sidebar.locator('a[href]').first()).toBeVisible();
+ await expect(sidebar).not.toHaveAttribute('inert', '');
+ });
+});
+
+test('blocked storage leaves active-path navigation usable', async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(window, 'localStorage', { get() { throw new DOMException('Blocked', 'SecurityError'); } });
+ });
+ await page.goto('/docs/introduction/');
+ const active = page.locator('#td-shell-sidebar .td-active-path [data-td-shell-tree-toggle]').first();
+ await expect(active).toHaveAttribute('aria-expanded', 'true');
+ await expect(page.locator('#td-shell-sidebar .td-shell-tree__link[aria-current="page"]')).toBeVisible();
+});
+
+test('aside TOC user preference persists across reload', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.addInitScript(() => localStorage.setItem('oink.sidebar.v2.latest.en', '[]'));
+ await page.goto('/docs/introduction/');
+ await page.evaluate(() => window.OinkSidebar.ready);
+ const toggle = page.locator('[data-td-shell-aside] [aria-controls="td-shell-aside-toc"]');
+ await expect(toggle).toBeVisible();
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ await toggle.click();
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await page.reload();
+ await page.evaluate(() => window.OinkSidebar.ready);
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await toggle.click();
+ await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('oink.sidebar.v3.latest.en'))))
+ .toContain('td-shell-aside-toc');
+ await page.reload();
+ await page.evaluate(() => window.OinkSidebar.ready);
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+});
+
+test('AI activation uses live text within the palette debounce window', async ({ page }) => {
+ test.skip(!process.env.AI_SITE_ROOT, 'AI-enabled fixture was not built');
+ await page.goto('http://127.0.0.1:4174/docs/');
+ await page.locator('[data-td-shell-search-open]').first().click();
+ const input = page.locator('.td-shell-search__input');
+ await input.fill('zzzxqstalezzzxq');
+ const tail = page.getByRole('option').filter({ hasText: 'Ask AI:' });
+ await expect(tail).toBeVisible();
+ await expect(tail).toHaveAttribute('aria-selected', 'true');
+ // One browser task guarantees Enter precedes OINK's 80 ms render timer.
+ await input.evaluate(input => {
+ input.value = 'zzzxqfreshzzzxq';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ });
+ await expect(page.locator('[data-hg-ai-consent]')).toBeVisible();
+ await page.route('https://widget.kapa.ai/kapa-widget.bundle.js*', route => route.fulfill({
+ contentType: 'text/javascript', body: `
+ var queued = window.Kapa.q.slice();
+ window.Kapa = function(method, value) {
+ if (method === 'render') value.onRender();
+ if (method === 'open') window.__submittedQuery = value.query;
+ };
+ queued.forEach(args => window.Kapa(...args));`,
+ }));
+ // Keep the consent open across the delayed render; it must not abort the handoff.
+ await page.waitForTimeout(160);
+ await expect(page.locator('[data-hg-ai-consent]')).toBeVisible();
+ await page.locator('[data-hg-ai-continue]').click();
+ await expect.poll(() => page.evaluate(() => window.__submittedQuery)).toBe('zzzxqfreshzzzxq');
+});
diff --git a/tests/e2e/visual.spec.js b/tests/e2e/visual.spec.js
index a2d4534ac..16cc81558 100644
--- a/tests/e2e/visual.spec.js
+++ b/tests/e2e/visual.spec.js
@@ -28,14 +28,16 @@ for (const [name, url, viewport, theme, state] of states) {
await expect(page.locator('[role="option"]').first()).toBeVisible();
} else if (state === "collapse") {
await page.locator(".td-shell-sidebar__collapse").click();
- await expect(page.locator("#td-shell-sidebar")).toHaveAttribute("aria-hidden", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-td-shell-sidebar", "collapsed");
} else if (state === "drawer") {
await page.locator("[data-td-shell-drawer-open]").click();
await expect(page.locator("html")).toHaveAttribute("data-td-shell-drawer", "open");
}
const directory = path.join(__dirname, "visual-results");
fs.mkdirSync(directory, { recursive: true });
+ await page.evaluate(() => document.fonts.ready);
await page.screenshot({
+ animations: "disabled",
path: path.join(directory, `${name}.png`),
fullPage: true
});
diff --git a/tests/e2e/workflow-contract.test.cjs b/tests/e2e/workflow-contract.test.cjs
index 9e6cbec0d..955007694 100644
--- a/tests/e2e/workflow-contract.test.cjs
+++ b/tests/e2e/workflow-contract.test.cjs
@@ -41,13 +41,23 @@ test("only publish receives write permission", () => {
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.equal(refs.length, 4);
assert.doesNotMatch(refs[0], /candidate_branch/);
- assert.equal(refs.slice(1).filter((ref) => ref.includes("needs.prepare.outputs.source_sha")).length, 4);
+ assert.equal(refs.slice(1).filter((ref) => ref.includes("needs.prepare.outputs.source_sha")).length, 3);
assert.doesNotMatch(workflow, /test \"\$GITHUB_REF\" = \"refs\/heads\/\$candidate\"/);
assert.match(workflow, /test \"\$GITHUB_REF\" = \"refs\/heads\/master\"/);
});
+test("trusted workflow bounds the candidate module graph without pinning its version", () => {
+ const step = workflow.split(" - name: Verify pinned OINK module\n")[1]
+ .split(" - name:")[0];
+ assert.match(step, /go list -m -f '\{\{ \.Path \}\}'.*github\.com\/apache\/hugegraph-doc/);
+ assert.match(step, /go list -m all \| wc -l.*-eq 2/);
+ assert.match(step, /test -z .*\.Replace.*github\.com\/pgsty\/oink/);
+ assert.doesNotMatch(step, /github\.com\/pgsty\/oink@v/);
+ assert.match(step, /python3 scripts\/update_oink\.py --check-baseline/);
+});
+
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 \}\}/);
@@ -57,3 +67,41 @@ test("dependency artifacts keep stable names across selective reruns", () => {
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);
});
+
+function jobBody(name) {
+ const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|$(?![\\s\\S]))`, "m"));
+ assert.ok(body, `missing job ${name}`);
+ return body[1];
+}
+
+test("superseded runs can cancel reporting and the required gate", () => {
+ assert.match(workflow, /group:.*format\('pr-\{0\}', github.event.pull_request.number\)/);
+ assert.match(workflow, /cancel-in-progress: true/);
+ assert.doesNotMatch(workflow, /if: always\(\)/);
+ assert.match(jobBody("deploy"), /if: \$\{\{ !cancelled\(\) \}\}/);
+});
+
+test("assembly runs blocking browser tests on its local artifact", () => {
+ const aggregate = jobBody("aggregate");
+ assert.doesNotMatch(workflow, /^ e2e:/m);
+ assert.match(aggregate, /SITE_ROOT: \$\{\{ runner.temp \}\}\/public-site/);
+ assert.match(aggregate, /run: npm run test:ci/);
+ assert.doesNotMatch(aggregate, /continue-on-error:/);
+ assert.match(jobBody("deploy"), /needs: \[prepare, build, aggregate\]/);
+ assert.match(jobBody("publish"), /needs: \[prepare, deploy\]/);
+});
+
+test("required gate rejects failed, skipped and cancelled prerequisites", () => {
+ const { spawnSync } = require("node:child_process");
+ const gate = jobBody("deploy").split(" run: |\n")[1]
+ .split("\n").filter(line => line.startsWith(" "))
+ .map(line => line.slice(10)).join("\n");
+ const success = { PREPARE_RESULT: "success", BUILD_RESULT: "success", AGGREGATE_RESULT: "success" };
+ const run = env => spawnSync("bash", ["-e", "-c", gate], {env: {...process.env, ...env}}).status;
+ assert.equal(run(success), 0);
+ for (const key of Object.keys(success)) {
+ for (const result of ["failure", "skipped", "cancelled"]) {
+ assert.notEqual(run({...success, [key]: result}), 0, `${key}=${result}`);
+ }
+ }
+});
diff --git a/tests/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs
index 997e1a8ab..dd373f3a8 100644
--- a/tests/ui-ai/kapa-adapter.test.cjs
+++ b/tests/ui-ai/kapa-adapter.test.cjs
@@ -131,6 +131,8 @@ test('uses one fixed bundle and explicit privacy-safe widget settings', () => {
themeColor: '#123456',
});
assert.equal(attrs['data-render-on-load'], 'false');
+ assert.equal(attrs['data-project-logo'], '/img/logo.svg');
+ assert.ok(Number(attrs['data-modal-z-index']) > 1040, 'modal must cover the site launcher');
assert.equal(attrs['data-launcher-button-hidden'], 'true');
assert.equal(attrs['data-search-mode-enabled'], 'false');
assert.equal(attrs['data-modal-open-on-command-k'], 'false');
@@ -295,114 +297,170 @@ test('init succeeds without search shell and binds standalone triggers', () => {
assert.equal(h.trigger.dataset.hgAiBound, '');
});
-test('init wires search shell Enter handler and updates noResults text', () => {
- global.MutationObserver = class {
- observe() {}
- disconnect() {}
- };
+test('registers native search rows with localized title and historical notice', async () => {
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() {},
- };
- },
- };
+ let extension;
+ h.windowObject.OinkCommandPalette = { registerSearchTail(value) { extension = value; } };
+ h.trigger.addEventListener = () => {};
+ h.documentObject.getElementById = (id) => id === 'hg-ai-config' ? {
+ textContent: JSON.stringify({ ...h.config, historical: true,
+ labels: { ask: 'Ask AI', latest: 'Answers use latest docs' } }),
+ } : null;
+ adapter.init(h.windowObject, h.documentObject);
+ assert.equal(extension.id, 'hugegraph-ai');
+ assert.deepEqual(extension.rows({ query: '<graph>' }), [{
+ id: 'ask', title: 'Ask AI: “<graph>”',
+ description: 'Answers use latest docs.', icon: 'fa-solid fa-wand-magic-sparkles',
+ }]);
+ const abort = new AbortController();
+ let handedOff = false;
+ const completion = extension.activate({}, {
+ query: 'graph query', signal: abort.signal,
+ handoff() { handedOff = true; return true; },
+ });
+ assert.equal(handedOff, true);
+ assert.equal(h.scripts.length, 0);
+ h.continueConsent();
+ h.scripts[0].fire('load');
+ h.fireRender();
+ await completion;
+ assert.deepEqual(h.calls.at(-1), ['open', { mode: 'ai', query: 'graph query', submit: true }]);
+});
- const controller = adapter.init(h.windowObject, doc);
- assert.ok(controller);
+test('search cancellation during consent or loading prevents stale widget opens', async () => {
+ for (const stage of ['consent', 'loading']) {
+ const h = harness();
+ const controller = adapter.createController(h.windowObject, h.documentObject, h.config);
+ const abort = new AbortController();
+ const completion = controller.activate('private', true, h.trigger, {
+ signal: abort.signal, handoff() { return true; },
+ });
+ if (stage === 'loading') h.continueConsent();
+ abort.abort();
+ await completion;
+ assert.equal(controller.getState(), 'idle');
+ if (stage === 'loading') {
+ h.scripts[0].fire('load');
+ h.fireRender();
+ assert.equal(h.scripts[0].removed, true);
+ }
+ assert.equal(h.calls.some(([method]) => method === 'open'), false);
+ assert.equal(h.trigger.focused, undefined, 'cancellation must not steal new palette focus');
+ }
+});
+
+test('search handoff refuses stale activation and load failure rejects completion', async () => {
+ const h = harness();
+ const controller = adapter.createController(h.windowObject, h.documentObject, h.config);
+ await controller.activate('stale', true, h.trigger, {
+ signal: new AbortController().signal, handoff() { return false; },
+ });
+ assert.equal(controller.getState(), 'idle');
+ const completion = controller.activate('retry', true, h.trigger, {
+ signal: new AbortController().signal, handoff() { return true; },
+ });
+ h.continueConsent();
+ h.scripts[0].fire('error');
+ await assert.rejects(completion, /unavailable/);
+ assert.equal(controller.getState(), 'error');
+});
- // 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() {},
+test('an aborted load retries with fresh callbacks and restores launcher focus on close', async () => {
+ const h = harness();
+ const controller = adapter.createController(h.windowObject, h.documentObject, h.config);
+ const abort = new AbortController();
+ const completion = controller.activate('cancelled', true, h.trigger, {
+ signal: abort.signal, handoff() { return true; },
});
- assert.equal(prevented, true);
h.continueConsent();
+ abort.abort();
+ await completion;
+ controller.activate('fresh', true, h.trigger);
h.installBundle();
- h.scripts[0].fire('load');
+ h.scripts[1].fire('load');
h.fireRender();
- assert.deepEqual(h.calls.at(-1), [
- 'open',
- { mode: 'ai', query: 'graph query', submit: true },
- ]);
+ assert.deepEqual(h.calls.at(-1), ['open', { mode: 'ai', query: 'fresh', submit: true }]);
+ h.calls.filter(([method]) => method === 'onModalClose').at(-1)[1]();
+ assert.equal(h.trigger.focused, true);
});
+test('widget API failures settle palette activation and permit a fresh retry', async () => {
+ for (const failingMethod of ['setSourceGroupIDs', 'open']) {
+ for (const stage of ['initial load', 'ready reopen']) {
+ const h = harness();
+ const controller = adapter.createController(h.windowObject, h.documentObject, h.config);
+ if (stage === 'ready reopen') {
+ controller.activate('', false, h.trigger);
+ h.continueConsent();
+ h.scripts[0].fire('load');
+ h.fireRender();
+ }
+ const originalKapa = h.windowObject.Kapa;
+ h.windowObject.Kapa = (method, value) => {
+ if (method === failingMethod) throw new Error('vendor failure');
+ return originalKapa(method, value);
+ };
+ const abort = new AbortController();
+ const completion = controller.activate('failing', true, h.trigger, {
+ signal: abort.signal, handoff() { return true; },
+ });
+ if (stage === 'initial load') {
+ h.continueConsent();
+ h.scripts[0].fire('load');
+ h.fireRender();
+ }
+ await assert.rejects(completion, /unavailable/);
+ assert.equal(controller.getState(), 'error');
+ assert.equal(h.trigger.disabled, false);
+ assert.equal(h.scripts[0].removed, true);
+ // Settlement must release the old cancellation listener.
+ abort.abort();
+ assert.equal(controller.getState(), 'error');
+
+ const retry = controller.activate('recovered', true, h.trigger, {
+ signal: new AbortController().signal, handoff() { return true; },
+ });
+ h.installBundle();
+ h.scripts[1].fire('load');
+ h.fireRender();
+ await retry;
+ assert.equal(controller.getState(), 'ready');
+ assert.deepEqual(h.calls.at(-1), ['open', {
+ mode: 'ai', query: 'recovered', submit: true,
+ }]);
+ }
+ }
+});
+test('stale activation refreshes the palette query and cancellation context', () => {
+ for (const query of ['fresh question', '', ' ', '> theme']) {
+ const h = harness();
+ let extension;
+ const refreshed = [];
+ const activated = [];
+ h.windowObject.OinkCommandPalette = {
+ registerSearchTail(value) { extension = value; },
+ instance: {
+ render(value) { refreshed.push(value); },
+ rows() { return query === 'fresh question' ? [
+ { type: 'page' }, { type: 'extension', owner: { id: 'hugegraph-ai' } },
+ ] : []; },
+ activate(index) { activated.push(index); },
+ },
+ };
+ h.trigger.addEventListener = () => {};
+ h.documentObject.getElementById = () => ({ textContent: JSON.stringify(h.config) });
+ const originalQuery = h.documentObject.querySelector;
+ h.documentObject.querySelector = selector =>
+ selector === '#td-shell-search .td-shell-search__input' ? { value: query } : originalQuery(selector);
+ adapter.init(h.windowObject, h.documentObject);
+ extension.activate({}, {
+ query: 'stale question', signal: new AbortController().signal,
+ handoff() { assert.fail('stale context must not hand off'); },
+ });
+ assert.deepEqual(refreshed, [query.trim()]);
+ assert.deepEqual(activated, query === 'fresh question' ? [1] : []);
+ assert.equal(h.scripts.length, 0);
+ }
+});
diff --git a/tests/ui-ai/sidebar-persistence.test.cjs b/tests/ui-ai/sidebar-persistence.test.cjs
new file mode 100644
index 000000000..bf1017443
--- /dev/null
+++ b/tests/ui-ai/sidebar-persistence.test.cjs
@@ -0,0 +1,130 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const shell = require('../../assets/js/hugegraph-shell.js');
+
+function fixture({ saved, legacy, asideExpanded = false, blocked = false, pathname = '/docs/', version = 'latest', locale = 'en' } = {}) {
+ const key = `oink.sidebar.v3.${version}.${locale}`;
+ const values = new Map(saved === undefined ? [] : [[key, saved]]);
+ if (legacy !== undefined) values.set(`oink.sidebar.v2.${version}.${locale}`, legacy);
+ const ids = ['root_navstart-children', 'root_navcomponents-children', 'root_navdevelop-children', 'active', 'aside-toc'];
+ const states = new Map(ids.map(id => [id, false]));
+ states.set('aside-toc', asideExpanded);
+ const calls = [];
+ const listeners = new Map();
+ let ready;
+ const api = {
+ ready: new Promise(resolve => { ready = resolve; }),
+ setExpanded(id, value, options) {
+ calls.push({ id, value, options });
+ states.set(id, id === 'active' || value);
+ },
+ getState(id) { return { id, expanded: states.get(id) }; },
+ };
+ const doc = {
+ querySelectorAll(selector) {
+ const selected = ids.filter(id => id !== 'aside-toc' || selector.includes('[data-td-shell-aside]'));
+ return selected.map(id => ({
+ getAttribute() { return id; },
+ closest(selector) {
+ if (selector === '[data-td-shell-aside]') return id === 'aside-toc' ? {} : null;
+ return { classList: { contains() { return id === 'active'; } } };
+ },
+ }));
+ },
+ addEventListener(name, listener) { listeners.set(name, listener); },
+ };
+ const win = {
+ OinkSidebar: api,
+ location: { pathname },
+ get localStorage() {
+ if (blocked) throw new Error('denied');
+ return {
+ getItem(name) { return values.get(name) ?? null; },
+ setItem(name, value) { values.set(name, value); },
+ removeItem(name) { values.delete(name); },
+ };
+ },
+ };
+ const initialized = shell.initTreePersistence(win, doc, { version, locale });
+ return { key, values, states, calls, ready, initialized, change(id, expanded, source) {
+ states.set(id, expanded);
+ listeners.get('oink:sidebar-disclosure')({ detail: { id, expanded, source } });
+ } };
+}
+
+test('waits for OINK hydration then restores docs defaults through the API', async () => {
+ const f = fixture();
+ assert.equal(f.calls.length, 0);
+ f.ready();
+ await f.initialized;
+ assert.equal(f.states.get('root_navstart-children'), true);
+ assert.equal(f.states.get('root_navcomponents-children'), true);
+ assert.equal(f.states.get('root_navdevelop-children'), false);
+ assert.equal(f.states.get('active'), true);
+ assert.ok(f.calls.every(call => call.options.source === 'api'));
+ assert.deepEqual(JSON.parse(f.values.get(f.key)), ['root_navstart-children', 'root_navcomponents-children']);
+});
+
+test('preserves stored empty choices and ignores automatic and non-sidebar events', async () => {
+ const f = fixture({ saved: '[]', version: '1.7', locale: 'cn', pathname: '/versions/1.7/cn/docs/' });
+ f.ready();
+ await f.initialized;
+ assert.equal(f.states.get('root_navstart-children'), false);
+ f.change('root_navdevelop-children', true, 'responsive');
+ assert.equal(f.values.get(f.key), '[]');
+ f.change('unrelated-disclosure', true, 'user');
+ assert.equal(f.values.get(f.key), '[]');
+ f.change('root_navdevelop-children', true, 'user');
+ assert.deepEqual(JSON.parse(f.values.get(f.key)), ['root_navdevelop-children']);
+ f.change('root_navdevelop-children', false, 'user');
+ assert.equal(f.values.get(f.key), '[]');
+});
+
+test('restores compatible stored ids and discards stale ids', async () => {
+ const f = fixture({ saved: '["root_navdevelop-children","removed",42]' });
+ f.ready();
+ await f.initialized;
+ assert.equal(f.states.get('root_navdevelop-children'), true);
+ assert.equal(f.states.get('root_navstart-children'), false);
+ assert.equal(f.calls.length, 5);
+});
+
+for (const options of [{ blocked: true }, { saved: '{bad' }, { saved: '{}' }]) {
+ test(`unavailable or invalid storage retains defaults: ${JSON.stringify(options)}`, async () => {
+ const f = fixture(options);
+ f.ready();
+ await f.initialized;
+ assert.equal(f.states.get('root_navstart-children'), true);
+ assert.equal(f.states.get('active'), true);
+ assert.doesNotThrow(() => f.change('root_navdevelop-children', true, 'user'));
+ });
+}
+
+test('restores and saves aside TOC disclosures through the same API', async () => {
+ const f = fixture({ saved: '["aside-toc"]', pathname: '/docs/introduction/' });
+ f.ready();
+ await f.initialized;
+ assert.equal(f.states.get('aside-toc'), true);
+ f.change('aside-toc', false, 'user');
+ assert.equal(f.values.get(f.key), '[]');
+ f.change('aside-toc', true, 'user');
+ assert.deepEqual(JSON.parse(f.values.get(f.key)), ['aside-toc']);
+});
+
+for (const legacy of ['[]', '["root_navdevelop-children"]']) {
+ test(`migrates main-tree v2 preferences without collapsing the aside: ${legacy}`, async () => {
+ const f = fixture({ legacy, asideExpanded: true });
+ f.ready();
+ await f.initialized;
+ assert.equal(f.states.get('aside-toc'), true);
+ assert.equal(f.states.get('root_navstart-children'), false);
+ assert.equal(f.states.get('root_navdevelop-children'), legacy !== '[]');
+ assert.deepEqual(JSON.parse(f.values.get(f.key)), [...JSON.parse(legacy), 'aside-toc']);
+ f.change('aside-toc', false, 'user');
+ const reloaded = fixture({ saved: f.values.get(f.key), legacy, asideExpanded: true });
+ reloaded.ready();
+ await reloaded.initialized;
+ assert.equal(reloaded.states.get('aside-toc'), false);
+ assert.equal(reloaded.states.get('root_navdevelop-children'), legacy !== '[]');
+ });
+}
diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs
index d4d3c30cd..a254c4a2c 100644
--- a/tests/ui-ai/ui-contract.test.cjs
+++ b/tests/ui-ai/ui-contract.test.cjs
@@ -6,10 +6,10 @@ const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
-test('disabled AI emits no widget or adapter markup', () => {
+test('AI is enabled behind the conditional adapter hook', () => {
const config = read('hugo.yaml');
const hook = read('layouts/_partials/hooks/body-end.html');
- assert.match(config, /ai_search:\n\s+enabled: false/);
+ assert.match(config, /ai_search:\n\s+enabled: true/);
assert.match(hook, /\{\{- if \$ai\.enabled -\}\}/);
assert.equal(config.includes('widget.kapa.ai'), false);
});
@@ -90,7 +90,9 @@ test('shell persistence uses the version and locale scoped key', () => {
assert.match(source, /oink\.sidebar\.v2\./);
assert.match(source, /config\.version/);
assert.match(source, /config\.locale/);
- assert.match(source, /sidebar\.inert = isolated/);
+ assert.match(source, /sidebar\.ready\.then/);
+ assert.match(source, /oink:sidebar-disclosure/);
+ assert.doesNotMatch(source, /initSidebarIsolation|setTreeExpanded/);
});
test('all three version selector surfaces expose one stable route contract', () => {