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/22 14:51:30 UTC
(hugegraph-doc) 01/01: feat: upgrade OINK and streamline theme updates
This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch oink-1.1
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit ba6fdbf01d16551286364fc788bc92eedffc703f
Author: dark <ji...@apache.org>
AuthorDate: Tue Sep 22 22:51:05 2026 +0800
feat: upgrade OINK and streamline theme updates
- adopt OINK 1.1 sidebar and search extension APIs
- reconcile print overrides and inherit upstream translations
- add reviewed upgrade tooling and compatibility checks
- refresh maintenance docs and regression coverage
---
.github/workflows/hugo.yml | 6 +-
AGENTS.md | 148 ++++-----------
NOTICE | 2 +-
README.md | 12 +-
assets/js/hugegraph-shell.js | 282 +++++------------------------
assets/js/kapa-adapter.js | 175 +++++++-----------
assets/scss/_styles_project.scss | 60 ------
contribution.md | 19 +-
go.mod | 3 +-
go.sum | 5 +-
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/oink-overrides.json | 97 ++++++++++
scripts/oink-upgrade.md | 106 +++++++++++
scripts/oink_module.py | 63 +++++++
scripts/test_download_data.py | 6 +-
scripts/test_oink_upgrade.py | 132 ++++++++++++++
scripts/update-oink.sh | 9 +
scripts/update_oink.py | 141 +++++++++++++++
scripts/versioning.py | 28 +--
tests/e2e/ai.spec.js | 69 ++++++-
tests/e2e/package.json | 2 +-
tests/e2e/platform.spec.js | 22 +--
tests/e2e/search-ranking.spec.js | 10 +-
tests/e2e/theme-upgrade.spec.js | 38 ++++
tests/e2e/visual.spec.js | 4 +-
tests/ui-ai/kapa-adapter.test.cjs | 180 ++++++++----------
tests/ui-ai/sidebar-persistence.test.cjs | 95 ++++++++++
tests/ui-ai/ui-contract.test.cjs | 4 +-
36 files changed, 1050 insertions(+), 951 deletions(-)
diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml
index f9c751bcc..bd0946e57 100644
--- a/.github/workflows/hugo.yml
+++ b/.github/workflows/hugo.yml
@@ -231,11 +231,7 @@ jobs:
restore-keys: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}-
- name: Verify pinned OINK module
run: |
- go mod verify
- test "$(go list -m -f '{{ .Path }}')" = "github.com/apache/hugegraph-doc"
- test "$(go list -m all | wc -l)" -eq 2
- test "$(go list -m -f '{{ .Path }}@{{ .Version }}' github.com/pgsty/oink)" = "github.com/pgsty/oink@v1.0.0"
- test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}@{{ .Version }}{{ end }}' github.com/pgsty/oink)"
+ python3 scripts/update_oink.py --check-baseline
- name: Build isolated version artifact
env:
OINK_PYTHON: python3
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..f01ba349b 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,59 @@
}
}
- 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]',
+ ));
+ 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) {
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;
+ if (storage) {
+ try {
+ var stored = storage.getItem(key);
+ 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);
+ }));
}
- }, 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. */
+ } 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 defaultOpen = !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) persist();
+ });
}
function initScrollableTables(documentObject) {
@@ -455,7 +268,6 @@
consumeVersionFallback(windowObject, documentObject, config);
initVersionSwitching(windowObject, documentObject);
initTreePersistence(windowObject, documentObject, config);
- initSidebarIsolation(windowObject, documentObject);
initScrollableTables(documentObject);
initSearchRetry(windowObject, documentObject);
}
@@ -464,7 +276,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..552ac9dfd 100644
--- a/assets/js/kapa-adapter.js
+++ b/assets/js/kapa-adapter.js
@@ -115,6 +115,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,6 +134,15 @@
}
}
+ 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', {
@@ -140,6 +150,7 @@
query: query,
submit: submit,
});
+ settle();
}
function discardAttempt(serial) {
@@ -172,6 +183,7 @@
windowObject.clearTimeout(timer);
discardAttempt(serial);
renderState('error', config.labels.error);
+ settle(new Error(config.labels.error));
}
function ready(serial, query, submit) {
@@ -230,7 +242,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 +256,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 +344,32 @@
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) {
+ // Search options are ephemeral and become hidden on handoff.
+ var trigger = documentObject.querySelector('.hg-ask-ai-launcher');
+ return controller.activate(context.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..84dd06983 100644
--- a/contribution.md
+++ b/contribution.md
@@ -14,23 +14,20 @@ 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).
## Local preview
@@ -71,7 +68,7 @@ A successful command proves that Hugo rendered the configured outputs. It does n
- `hugo.yaml` owns routing, languages, outputs, search, navigation, and OINK parameters.
- `data/home/<language>.yaml` owns the bilingual homepage.
- `data/footer/<language>.yaml` owns the bilingual footer.
-- `i18n/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/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/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..b2d39dbcd
--- /dev/null
+++ b/scripts/oink-upgrade.md
@@ -0,0 +1,106 @@
+# 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 and 4174 must be available.
+
+```sh
+scripts/update-oink.sh v1.1.0
+```
+
+Use an explicit release tag. The command updates the lock files, 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,
+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.
+
+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
new file mode 100644
index 000000000..81b0575d2
--- /dev/null
+++ b/scripts/oink_module.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Resolve the sole theme dependency from the checked-in Go lock files."""
+import json
+import os
+import re
+import subprocess
+from pathlib import Path
+
+MODULE = "github.com/pgsty/oink"
+SITE_MODULE = "github.com/apache/hugegraph-doc"
+VERSION = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?")
+
+
+def command(args, root):
+ return subprocess.check_output([os.environ.get("GO_BIN", "go"), *args], cwd=root, text=True)
+
+
+def locked_version(root):
+ data = json.loads(command(["mod", "edit", "-json"], root))
+ requirements = data.get("Require") or []
+ if (data.get("Module", {}).get("Path") != SITE_MODULE
+ or data.get("Replace") or data.get("Exclude")
+ or len(requirements) != 1 or requirements[0]["Path"] != MODULE):
+ raise ValueError("expected the HugeGraph module with only OINK and no replace/exclude directives")
+ version = requirements[0]["Version"]
+ if not VERSION.fullmatch(version):
+ raise ValueError("OINK must be pinned to an explicit release version")
+ return version
+
+
+def download_locked(root):
+ root = Path(root)
+ version = locked_version(root)
+ sums = {}
+ for line in (root / "go.sum").read_text().splitlines():
+ path, revision, digest = line.split()
+ sums[(path, revision)] = digest
+ expected = [sums.get((MODULE, version)), sums.get((MODULE, version + "/go.mod"))]
+ if not all(expected):
+ raise ValueError("OINK module and go.mod checksums must already exist in go.sum")
+ module = json.loads(command(["mod", "download", "-json", MODULE + "@" + version], root))
+ if (module.get("Path") != MODULE or module.get("Version") != version
+ or module.get("Sum") != expected[0] or module.get("GoModSum") != expected[1]
+ or module.get("Error")):
+ raise ValueError("downloaded OINK identity or checksum differs from lock files")
+ resolved = json.loads(command(["list", "-m", "-json", MODULE], root))
+ if resolved.get("Replace") or resolved.get("Version") != version or resolved.get("Path") != MODULE:
+ raise ValueError("resolved OINK module differs from lock files")
+ graph = command(["list", "-m", "all"], root).splitlines()
+ if graph != [SITE_MODULE, MODULE + " " + version]:
+ raise ValueError("module graph must contain only this site and the pinned OINK module")
+ command(["mod", "verify"], root)
+ return module
+
+
+if __name__ == "__main__":
+ print(json.dumps(download_locked(Path(__file__).resolve().parents[1]), indent=2))
diff --git a/scripts/test_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_upgrade.py b/scripts/test_oink_upgrade.py
new file mode 100644
index 000000000..372a91edd
--- /dev/null
+++ b/scripts/test_oink_upgrade.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import oink_module
+import update_oink
+
+
+class ModuleLockTest(unittest.TestCase):
+ def config(self, **extra):
+ return json.dumps(dict(Module={"Path": oink_module.SITE_MODULE},
+ Require=[{"Path": oink_module.MODULE, "Version": "v1.1.0"}], **extra))
+
+ def test_lock_accepts_release_and_rejects_replacement(self):
+ with patch.object(oink_module, "command", return_value=self.config()):
+ self.assertEqual(oink_module.locked_version(Path(".")), "v1.1.0")
+ with patch.object(oink_module, "command", return_value=self.config(Replace=[{"New": {"Path": "local"}}])):
+ with self.assertRaises(ValueError):
+ oink_module.locked_version(Path("."))
+
+ def test_checksum_mismatch_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = Path(d)
+ (root / "go.sum").write_text(f"{oink_module.MODULE} v1.1.0 h1:expected\n{ oink_module.MODULE} v1.1.0/go.mod h1:mod\n")
+ with patch.object(oink_module, "command", side_effect=[self.config(), json.dumps({"Path": oink_module.MODULE, "Version": "v1.1.0", "Sum": "h1:wrong", "GoModSum": "h1:mod"})]):
+ with self.assertRaisesRegex(ValueError, "checksum"):
+ oink_module.download_locked(root)
+
+ def test_missing_sum_does_not_download(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = Path(d)
+ (root / "go.sum").write_text("")
+ with patch.object(oink_module, "command", return_value=self.config()) as command:
+ with self.assertRaises(ValueError):
+ oink_module.download_locked(root)
+ self.assertEqual(command.call_count, 1)
+
+
+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, "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, "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, "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/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..0847b48f0
--- /dev/null
+++ b/scripts/update_oink.py
@@ -0,0 +1,141 @@
+#!/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"):
+ 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 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")))
+ run([*cli, "config", "--version", "latest", "--site-origin", "http://127.0.0.1:4174/",
+ "--historical-origin", historical, "--output", work / "ai.json"])
+ run([os.environ.get("HUGO_BIN", "hugo"), "--config",
+ f"hugo.yaml,{work / 'ai.json'},tests/e2e/ai-enabled.yaml",
+ "--destination", work / "ai", "--cleanDestinationDir", "--gc", "--minify",
+ "--environment", "production", "--panicOnWarning"])
+ env = dict(os.environ, SITE_ROOT=str(work / "site"), AI_SITE_ROOT=str(work / "ai"))
+ 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())
+ if (baseline["version"] != module["Version"]
+ or changes(baseline["files"], snapshot(ROOT, Path(module["Dir"])))):
+ raise ValueError("OINK upstream files differ from reviewed baseline; 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)
+ 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 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 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 84d9a2d5f..e9b48cc24 100644
--- a/scripts/versioning.py
+++ b/scripts/versioning.py
@@ -34,6 +34,8 @@ import urllib.parse
import xml.etree.ElementTree as ET
from typing import NoReturn
+from oink_module import download_locked
+
ROOT = pathlib.Path(__file__).resolve().parents[1]
URL_CONTRACT = ROOT / "dist/url-contract.json"
@@ -4068,30 +4070,10 @@ def build(args: argparse.Namespace) -> None:
json.dumps(override, ensure_ascii=False), encoding="utf-8"
)
hugo = os.environ.get("HUGO_BIN", "hugo")
- go = os.environ.get("GO_BIN", "go")
- go_executable = shutil.which(go)
+ go_executable = shutil.which(os.environ.get("GO_BIN", "go"))
if go_executable is None:
- fail(f"Go executable is unavailable: {go}")
- module_result = subprocess.run(
- [
- go_executable,
- "mod",
- "download",
- "-json",
- "github.com/pgsty/oink@v1.0.0",
- ],
- cwd=assembly,
- check=True,
- stdout=subprocess.PIPE,
- text=True,
- )
- module = json.loads(module_result.stdout)
- if (
- module.get("Path") != "github.com/pgsty/oink"
- or module.get("Version") != "v1.0.0"
- or module.get("Sum") != "h1:E+WHFP9zSRT+5RKoIkWNp+ASRGS1BKG+rDEi9by/BjE="
- ):
- fail(f"unexpected OINK module metadata: {module!r}")
+ fail("Go executable is unavailable")
+ module = download_locked(assembly)
migration_script = pathlib.Path(module["Dir"]) / "bin/migrations/oink06.py"
if not migration_script.is_file():
fail(f"pinned OINK migration tool is absent: {migration_script}")
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..dfa338a6d 100644
--- a/tests/e2e/platform.spec.js
+++ b/tests/e2e/platform.spec.js
@@ -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", "");
});
diff --git a/tests/e2e/search-ranking.spec.js b/tests/e2e/search-ranking.spec.js
index 06f79a4a6..270322106 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, "PR-B search metadata fixture is not integrated");
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..b129c6e01
--- /dev/null
+++ b/tests/e2e/theme-upgrade.spec.js
@@ -0,0 +1,38 @@
+const { test, expect } = require('./artifact-test');
+
+for (const prefix of ['', '/cn', '/versions/1.7', '/versions/1.7/cn']) {
+ test(`print preserves authored content after theme upgrade: ${prefix || 'en'}`, async ({ page }) => {
+ 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();
+});
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/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs
index 997e1a8ab..1b157f699 100644
--- a/tests/ui-ai/kapa-adapter.test.cjs
+++ b/tests/ui-ai/kapa-adapter.test.cjs
@@ -295,114 +295,90 @@ 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);
});
-
-
diff --git a/tests/ui-ai/sidebar-persistence.test.cjs b/tests/ui-ai/sidebar-persistence.test.cjs
new file mode 100644
index 000000000..46833cb8d
--- /dev/null
+++ b/tests/ui-ai/sidebar-persistence.test.cjs
@@ -0,0 +1,95 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const shell = require('../../assets/js/hugegraph-shell.js');
+
+function fixture({ saved, blocked = false, pathname = '/docs/', version = 'latest', locale = 'en' } = {}) {
+ const key = `oink.sidebar.v2.${version}.${locale}`;
+ const values = new Map(saved === undefined ? [] : [[key, saved]]);
+ const ids = ['root_navstart-children', 'root_navcomponents-children', 'root_navdevelop-children', 'active'];
+ const states = new Map(ids.map(id => [id, false]));
+ 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() {
+ return ids.map(id => ({
+ getAttribute() { return id; },
+ closest() { 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('aside-toc', 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, 4);
+});
+
+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'));
+ });
+}
diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs
index d4d3c30cd..aa0f5f2ba 100644
--- a/tests/ui-ai/ui-contract.test.cjs
+++ b/tests/ui-ai/ui-contract.test.cjs
@@ -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', () => {