This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch feat/oink-core-platform in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit 61e20f4555b066e43ef1f99a82a0836450c6d80e Author: dark <[email protected]> AuthorDate: Fri Sep 4 19:02:06 2026 +0800 feat(ui): add oink shell and ai extensions - add five-group docs navigation and explicit sidebar recovery\n- isolate and persist sidebar state by version and locale\n- add click-gated Kapa adapter with strict privacy defaults\n- enable OINK content helpers and branded social fallback\n- cover UI and AI contracts with dependency-free Node tests --- assets/js/hugegraph-shell.js | 251 ++++++++++++++++++++++++++ assets/js/kapa-adapter.js | 310 ++++++++++++++++++++++++++++++++ assets/scss/_styles_project.scss | 154 +++++++++++++++- hugo.yaml | 23 +++ layouts/_partials/ai/config.html | 33 ++++ layouts/_partials/hooks/body-end.html | 41 +++++ layouts/_partials/hooks/head-end.html | 5 + layouts/_partials/navbar-item.html | 16 +- layouts/_partials/navbar.html | 7 + layouts/_partials/share/bar.html | 13 ++ static/img/social/hugegraph-default.png | Bin 0 -> 984595 bytes tests/ui-ai/kapa-adapter.test.cjs | 147 +++++++++++++++ tests/ui-ai/ui-contract.test.cjs | 47 +++++ 13 files changed, 1027 insertions(+), 20 deletions(-) diff --git a/assets/js/hugegraph-shell.js b/assets/js/hugegraph-shell.js new file mode 100644 index 000000000..a66f6ec36 --- /dev/null +++ b/assets/js/hugegraph-shell.js @@ -0,0 +1,251 @@ +/** + * HugeGraph additions around OINK's shell. + * + * This file deliberately does not replace OINK's command palette. It only + * persists authored tree disclosures, makes a collapsed/dismissed sidebar + * inert, and adds an explicit retry control to the existing search error. + */ +(function (global) { + 'use strict'; + + function readConfig(documentObject) { + var node = documentObject.getElementById('hg-shell-config'); + if (!node) return { version: 'latest', locale: 'en' }; + try { + return JSON.parse(node.textContent || '{}'); + } catch (_) { + return { version: 'latest', locale: 'en' }; + } + } + + function safeStorage(windowObject) { + try { + var storage = windowObject.localStorage; + var probe = '__hg_sidebar_probe__'; + storage.setItem(probe, '1'); + storage.removeItem(probe); + return storage; + } catch (_) { + return null; + } + } + + function setTreeExpanded(button, expanded, documentObject) { + var target = documentObject.getElementById( + button.getAttribute('aria-controls'), + ); + if (!target) return; + button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + target.classList.toggle('td-is-open', expanded); + var label = expanded + ? button.dataset.tdLabelCollapse + : button.dataset.tdLabelExpand; + if (label) button.setAttribute('aria-label', label); + } + + function initTreePersistence(windowObject, documentObject, config) { + var buttons = Array.prototype.slice.call( + documentObject.querySelectorAll('[data-td-shell-tree-toggle][aria-controls]'), + ); + if (!buttons.length) return; + var storage = safeStorage(windowObject); + var key = + 'oink.sidebar.v1.' + + String(config.version || 'latest') + + '.' + + String(config.locale || 'en'); + var valid = new Set( + buttons.map(function (button) { + return button.getAttribute('aria-controls'); + }), + ); + var saved = []; + if (storage) { + try { + var parsed = JSON.parse(storage.getItem(key) || '[]'); + if (Array.isArray(parsed)) { + saved = parsed.filter(function (id) { + return typeof id === 'string' && valid.has(id); + }); + } + } catch (_) { + saved = []; + } + } + var remembered = new Set(saved); + + buttons.forEach(function (button) { + var item = button.closest('li'); + var activePath = item && item.classList.contains('td-active-path'); + setTreeExpanded( + button, + Boolean(activePath || remembered.has(button.getAttribute('aria-controls'))), + documentObject, + ); + button.addEventListener('click', function () { + global.queueMicrotask(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. */ + } + }); + }); + }); + + // Rewriting the filtered set removes stale node IDs after navigation + // changes without retaining a second schema/version marker. + if (storage) { + try { + storage.setItem(key, JSON.stringify(saved)); + } catch (_) { + /* Ignore storage becoming unavailable after the probe. */ + } + } + } + + function initSidebarIsolation(windowObject, documentObject) { + var html = documentObject.documentElement; + var sidebar = documentObject.getElementById('td-shell-sidebar'); + if (!sidebar) return; + var restore = documentObject.querySelector('.hg-sidebar-restore'); + var desktop = windowObject.matchMedia('(min-width: 768px)'); + + function sync() { + var collapsed = + html.getAttribute('data-td-shell-sidebar') === 'collapsed'; + var drawerOpen = + html.getAttribute('data-td-shell-drawer') === 'open'; + var isolated = desktop.matches ? collapsed : !drawerOpen; + sidebar.inert = isolated; + if (isolated) sidebar.setAttribute('aria-hidden', 'true'); + else sidebar.removeAttribute('aria-hidden'); + if ( + isolated && + sidebar.contains(documentObject.activeElement) && + restore && + restore.offsetParent !== null + ) { + restore.focus(); + } + } + + new MutationObserver(sync).observe(html, { + attributes: true, + attributeFilter: ['data-td-shell-sidebar', 'data-td-shell-drawer'], + }); + desktop.addEventListener('change', sync); + documentObject + .querySelectorAll('[data-td-shell-sidebar-toggle], [data-td-shell-drawer-close]') + .forEach(function (button) { + button.addEventListener('click', function () { + global.queueMicrotask(sync); + }); + }); + sync(); + } + + function initSearchRetry(windowObject, documentObject) { + var root = documentObject.getElementById('td-shell-search'); + if (!root) return; + var list = root.querySelector('.td-shell-search__list'); + var input = root.querySelector('.td-shell-search__input'); + var status = root.querySelector('[data-td-shell-search-status]'); + if (!list || !input || !status) return; + var scheduled = false; + + function sync() { + scheduled = false; + var existing = list.querySelector('[data-hg-search-retry]'); + if (existing) existing.remove(); + var failure = root.dataset.tdTIndexUnavailable || ''; + var failed = + failure && + (status.textContent.trim() === failure || + Array.prototype.some.call( + list.querySelectorAll('.td-shell-search__empty'), + function (node) { + return node.textContent.trim() === failure; + }, + )); + if (!failed) return; + + var notice = documentObject.createElement('div'); + notice.className = 'hg-search-retry'; + notice.dataset.hgSearchRetry = ''; + var text = documentObject.createElement('span'); + text.textContent = failure; + var button = documentObject.createElement('button'); + button.type = 'button'; + button.className = 'btn btn-sm btn-outline-primary'; + button.textContent = + documentObject.documentElement.lang === 'cn' || + documentObject.documentElement.lang.indexOf('zh') === 0 + ? '重试' + : 'Retry'; + button.addEventListener('click', function () { + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + }); + notice.appendChild(text); + notice.appendChild(button); + list.appendChild(notice); + } + + function schedule() { + if (scheduled) return; + scheduled = true; + global.requestAnimationFrame(sync); + } + new MutationObserver(schedule).observe(list, { + childList: true, + subtree: true, + characterData: true, + }); + new MutationObserver(schedule).observe(status, { + childList: true, + subtree: true, + characterData: true, + }); + schedule(); + } + + function init(windowObject, documentObject) { + var config = readConfig(documentObject); + initTreePersistence(windowObject, documentObject, config); + initSidebarIsolation(windowObject, documentObject); + initSearchRetry(windowObject, documentObject); + } + + var api = { + init: init, + readConfig: readConfig, + safeStorage: safeStorage, + setTreeExpanded: setTreeExpanded, + }; + global.HugeGraphShell = api; + if (typeof module === 'object' && module.exports) module.exports = api; + + if (global.document) { + if (global.document.readyState === 'loading') { + global.document.addEventListener('DOMContentLoaded', function () { + init(global, global.document); + }); + } else { + init(global, global.document); + } + } +})(typeof window === 'object' ? window : globalThis); diff --git a/assets/js/kapa-adapter.js b/assets/js/kapa-adapter.js new file mode 100644 index 000000000..9cb1cede6 --- /dev/null +++ b/assets/js/kapa-adapter.js @@ -0,0 +1,310 @@ +/** + * Click-gated Kapa adapter for OINK. + * + * The third-party bundle URL and privacy posture are fixed here. The page + * supplies only reviewed public identifiers and localized labels. + */ +(function (global) { + 'use strict'; + + var BUNDLE_URL = 'https://widget.kapa.ai/kapa-widget.bundle.js'; + var TIMEOUT_MS = 5000; + + function trimmedQuery(value) { + return String(value || '').trim(); + } + + function readConfig(documentObject) { + var node = documentObject.getElementById('hg-ai-config'); + if (!node) return null; + try { + var config = JSON.parse(node.textContent || '{}'); + return config.websiteId && config.sourceGroupId ? config : null; + } catch (_) { + return null; + } + } + + function invokeKapa(windowObject, method, value) { + var api = windowObject.Kapa; + if (typeof api === 'function') return api(method, value); + if (api && typeof api[method] === 'function') return api[method](value); + throw new Error('Kapa API is unavailable'); + } + + function preinitialize(windowObject) { + if (windowObject.Kapa) return; + var queue = function () { + queue.c(arguments); + }; + queue.q = []; + queue.c = function (args) { + queue.q.push(args); + }; + windowObject.Kapa = queue; + } + + function scriptAttributes(config) { + return { + 'data-website-id': config.websiteId, + 'data-source-group-ids-include': config.sourceGroupId, + 'data-language': config.locale, + 'data-project-name': 'Apache HugeGraph', + 'data-project-color': '#532fc9', + 'data-project-color-dark': '#9f83ff', + 'data-surface-color': '#ffffff', + 'data-surface-elevated-color': '#f6f4fb', + 'data-surface-hover-color': '#eeeafd', + 'data-text-color': '#24212d', + 'data-text-muted-color': '#686275', + 'data-border-color': '#d9d4e4', + 'data-anchor-color': '#532fc9', + 'data-surface-color-dark': '#17151d', + 'data-surface-elevated-color-dark': '#221f2b', + 'data-surface-hover-color-dark': '#302b3d', + 'data-text-color-dark': '#f0edf7', + 'data-text-muted-color-dark': '#b6afc2', + 'data-border-color-dark': '#494254', + 'data-anchor-color-dark': '#b6a3ff', + 'data-color-scheme-selector': "[data-bs-theme='dark']", + 'data-font-family': + '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif', + 'data-modal-content-border-radius': '12px', + 'data-modal-content-border': '1px solid #d9d4e4', + 'data-modal-content-border-dark': '1px solid #494254', + 'data-launcher-button-hidden': 'true', + 'data-render-on-load': 'false', + 'data-search-mode-enabled': 'false', + 'data-modal-open-on-command-k': 'false', + 'data-consent-required': 'false', + 'data-user-analytics-cookie-enabled': 'false', + 'data-user-analytics-fingerprint-enabled': 'false', + 'data-exit-feedback-enabled': 'false', + 'data-user-satisfaction-feedback-enabled': 'false', + 'data-bot-protection-mechanism': 'hcaptcha', + }; + } + + function createController(windowObject, documentObject, config) { + var state = 'idle'; + var attempt = 0; + var timer = 0; + var lastTrigger = null; + var status = documentObject.querySelector('[data-hg-ai-status]'); + + function renderState(next, message) { + state = next; + documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(function (button) { + button.dataset.hgAiState = next; + button.disabled = next === 'loading'; + if (next === 'loading') button.setAttribute('aria-busy', 'true'); + else button.removeAttribute('aria-busy'); + if (message) button.title = message; + }); + if (status) { + status.textContent = message || ''; + status.classList.toggle('visually-hidden', !message); + } + } + + function openWidget(query, submit) { + invokeKapa(windowObject, 'setSourceGroupIDs', [config.sourceGroupId]); + invokeKapa(windowObject, 'open', { + mode: 'ai', + query: query, + submit: submit, + }); + } + + function fail(serial) { + if (serial !== attempt || state !== 'loading') return; + windowObject.clearTimeout(timer); + renderState('error', config.labels.error); + } + + function ready(serial, query, submit) { + if (serial !== attempt || state !== 'loading') return; + windowObject.clearTimeout(timer); + renderState('ready', ''); + openWidget(query, submit); + } + + function ensureScript(serial, query, submit) { + var script = documentObject.querySelector( + 'script[data-hg-kapa-widget]', + ); + if (!script) { + preinitialize(windowObject); + script = documentObject.createElement('script'); + script.async = true; + script.src = BUNDLE_URL; + script.dataset.hgKapaWidget = ''; + var attrs = scriptAttributes(config); + Object.keys(attrs).forEach(function (name) { + script.setAttribute(name, attrs[name]); + }); + script.addEventListener('error', function () { + script.remove(); + fail(serial); + }, { once: true }); + documentObject.head.appendChild(script); + } + try { + invokeKapa(windowObject, 'render', { + onRender: function () { + ready(serial, query, submit); + }, + }); + } catch (_) { + fail(serial); + } + } + + function activate(query, submit, trigger) { + query = trimmedQuery(query); + lastTrigger = trigger || documentObject.activeElement; + if (state === 'loading') return; + if (state === 'ready') { + openWidget(query, Boolean(submit && query)); + return; + } + var serial = ++attempt; + renderState('loading', ''); + timer = windowObject.setTimeout(function () { + fail(serial); + }, TIMEOUT_MS); + ensureScript(serial, query, Boolean(submit && query)); + } + + preinitialize(windowObject); + invokeKapa(windowObject, 'onModalClose', function () { + if (lastTrigger && typeof lastTrigger.focus === 'function') { + lastTrigger.focus(); + } + }); + + return { + activate: activate, + getState: function () { return state; }, + }; + } + + function init(windowObject, documentObject) { + var config = readConfig(documentObject); + if (!config) return null; + var controller = createController(windowObject, documentObject, config); + var root = documentObject.getElementById('td-shell-search'); + var input = root && root.querySelector('.td-shell-search__input'); + var list = root && root.querySelector('.td-shell-search__list'); + var syncing = false; + + function bind(button) { + if (button.dataset.hgAiBound !== undefined) return; + button.dataset.hgAiBound = ''; + button.addEventListener('click', function () { + controller.activate( + button.dataset.hgAiQuery || '', + button.dataset.hgAiSubmit === 'true', + button, + ); + }); + } + documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(bind); + + function syncTail() { + syncing = false; + if (!root || !input || !list || root.hidden) return; + var old = list.querySelector('[data-hg-ai-search-tail]'); + if (old) old.remove(); + var query = trimmedQuery(input.value); + if (!query || query.charAt(0) === '>') 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; }, + ) + ) 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; }, + ) + ) return; + + var group = documentObject.createElement('div'); + group.className = 'td-shell-search__group hg-ai-search-tail'; + group.dataset.hgAiSearchTail = ''; + group.setAttribute('role', 'group'); + var label = documentObject.createElement('div'); + label.className = 'td-shell-search__group-label'; + label.textContent = config.labels.ask; + var row = documentObject.createElement('button'); + row.type = 'button'; + row.className = 'td-shell-search__item hg-ai-search-tail__button'; + row.dataset.hgAskAi = ''; + row.dataset.hgAiQuery = query; + row.dataset.hgAiSubmit = 'true'; + var icon = documentObject.createElement('i'); + icon.className = + 'fa-solid fa-wand-magic-sparkles td-shell-search__item-icon'; + icon.setAttribute('aria-hidden', 'true'); + var meta = documentObject.createElement('span'); + meta.className = 'td-shell-search__item-meta'; + var title = documentObject.createElement('span'); + title.className = 'td-shell-search__item-title'; + title.textContent = config.labels.ask + ': “' + query + '”'; + var detail = documentObject.createElement('span'); + detail.className = 'td-shell-search__item-ref'; + detail.textContent = + config.labels.description + + (config.historical ? ' ' + config.labels.latest + '.' : ''); + meta.appendChild(title); + meta.appendChild(detail); + row.appendChild(icon); + row.appendChild(meta); + group.appendChild(label); + group.appendChild(row); + list.appendChild(group); + bind(row); + } + + if (list) { + new MutationObserver(function () { + if (syncing) return; + syncing = true; + windowObject.requestAnimationFrame(syncTail); + }).observe(list, { childList: true, subtree: true }); + input.addEventListener('input', syncTail); + syncTail(); + } + return controller; + } + + var api = { + BUNDLE_URL: BUNDLE_URL, + TIMEOUT_MS: TIMEOUT_MS, + createController: createController, + init: init, + invokeKapa: invokeKapa, + preinitialize: preinitialize, + readConfig: readConfig, + scriptAttributes: scriptAttributes, + trimmedQuery: trimmedQuery, + }; + global.HugeGraphKapa = api; + if (typeof module === 'object' && module.exports) module.exports = api; + + if (global.document) { + if (global.document.readyState === 'loading') { + global.document.addEventListener('DOMContentLoaded', function () { + init(global, global.document); + }); + } else { + init(global, global.document); + } + } +})(typeof window === 'object' ? window : globalThis); diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index e0941c5d5..19330f863 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -1,6 +1,28 @@ // HugeGraph homepage: preserve the original product copy and isometric brand // artwork while using OINK's accessible navigation and landing primitives. -$hg-navbar-purple: #532fc9; +// `params.ui.theme_color` publishes the project accent as this CSS custom +// property in hooks/head-end.html. + +:root { + --hg-theme-color-soft: color-mix( + in srgb, + var(--hg-theme-color) 12%, + var(--bs-body-bg) + ); + --hg-theme-color-hover: color-mix( + in srgb, + var(--hg-theme-color) 18%, + var(--bs-body-bg) + ); +} + +:where(a, button, input, summary):focus-visible { + outline-color: var(--hg-theme-color); +} + +::selection { + background: var(--hg-theme-color-soft); +} .td-home { // Keep OINK's display face for headings, but restore the original site's @@ -39,7 +61,7 @@ $hg-navbar-purple: #532fc9; } &.td-scrolled { - background: rgba($hg-navbar-purple, 0.96); + background: color-mix(in srgb, var(--hg-theme-color) 96%, transparent); box-shadow: 0 6px 22px rgba(20, 17, 73, 0.24); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); @@ -248,7 +270,7 @@ $hg-navbar-purple: #532fc9; // OINK's sticky header, search, version, language, theme, and help controls. .td-shell-chrome .td-site-header { border-block-end-color: rgba(255, 255, 255, 0.22); - background: $hg-navbar-purple; + background: var(--hg-theme-color); color: #fff; -webkit-backdrop-filter: none; backdrop-filter: none; @@ -310,9 +332,9 @@ $hg-navbar-purple: #532fc9; margin: calc(-1 * #{$spacer}) calc(-1 * #{$spacer}) 0; padding-inline: $spacer; border-block-end: 1px solid rgba(255, 255, 255, 0.22); - background: $hg-navbar-purple; + background: var(--hg-theme-color); color: #fff; - box-shadow: 1px 0 $hg-navbar-purple; + box-shadow: 1px 0 var(--hg-theme-color); .td-shell-wordmark { background: none; @@ -465,7 +487,129 @@ $hg-navbar-purple: #532fc9; display: none; } +.hg-sidebar-restore { + display: none; +} + +@media (min-width: 768px) { + [data-td-shell-sidebar='collapsed'] { + #td-shell-sidebar { + visibility: hidden; + pointer-events: none; + } + + // OINK v1.0 exposes a left-edge hover overlay after collapse. HugeGraph's + // explicit restore control replaces that hidden target entirely. + #td-shell-sidebar.td-shell-sidebar--overlay + .td-shell-sidebar__panel { + visibility: hidden; + transform: translateX(-100%); + pointer-events: none; + } + + .hg-sidebar-restore { + display: inline-flex; + } + } +} + +.hg-search-retry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0.5rem 0.75rem 0.75rem; + padding: 0.7rem 0.8rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + font-size: 0.84rem; +} + +.hg-ai-search-tail { + border-block-start: 1px solid var(--bs-border-color); + + &__button { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: start; + + &:hover, + &:focus-visible { + background: var(--hg-theme-color-soft); + color: inherit; + } + } +} + +.hg-ask-ai-launcher { + position: fixed; + z-index: 1040; + inset-inline-end: max(1rem, env(safe-area-inset-right)); + inset-block-end: max(1rem, env(safe-area-inset-bottom)); + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 44px; + padding: 0.65rem 0.9rem; + border: 1px solid color-mix(in srgb, var(--hg-theme-color) 72%, #fff); + border-radius: 999px; + background: var(--hg-theme-color); + box-shadow: 0 8px 24px rgb(20 17 73 / 24%); + color: #fff; + font: 600 0.86rem/1 var(--td-ui-font-family); + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--hg-theme-color) 86%, #000); + color: #fff; + } + + &[data-hg-ai-state='error'] { + border-style: dashed; + } + + &[aria-busy='true'] { + cursor: wait; + opacity: 0.72; + } +} + +.hg-ai-status:not(:empty) { + position: fixed; + z-index: 1039; + inset-inline-end: max(1rem, env(safe-area-inset-right)); + inset-block-end: calc(max(1rem, env(safe-area-inset-bottom)) + 3.4rem); + width: min(18rem, calc(100vw - 2rem)); + padding: 0.55rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.55rem; + background: var(--bs-body-bg); + box-shadow: 0 6px 18px rgb(0 0 0 / 14%); + color: var(--bs-body-color); + font-size: 0.78rem; +} + @media (max-width: 767.98px) { + .td-shell-chrome .td-site-nav__menu-toggle { + border: 1px solid rgb(255 255 255 / 42%); + background: rgb(20 17 73 / 22%); + color: #fff; + } + + .hg-ask-ai-launcher { + min-width: 44px; + min-height: 44px; + padding: 0.65rem; + + span { + @include visually-hidden; + } + } + .hg-shell-mobile-utils, .hg-landing-mobile-utils { display: grid; diff --git a/hugo.yaml b/hugo.yaml index 8b0afe361..febee991a 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -24,6 +24,11 @@ languages: menus: main: - { identifier: docs, name: Documentation, pageRef: /docs, weight: 10 } + - { identifier: docs-start, parent: docs, name: Get Started, pageRef: /docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } } + - { identifier: docs-components, parent: docs, name: Components, pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } } + - { identifier: docs-develop, parent: docs, name: Develop, pageRef: /docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } } + - { identifier: docs-operate, parent: docs, name: Operate, pageRef: /docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } } + - { identifier: docs-reference, parent: docs, name: Reference, pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } } - { identifier: download, name: Download, pageRef: /docs/download/download, weight: 20 } - { identifier: blog, name: Blog, pageRef: /blog, weight: 30 } - { identifier: community, name: Community, pageRef: /community, weight: 40 } @@ -45,6 +50,11 @@ languages: menus: main: - { identifier: docs, name: 文档, pageRef: /docs, weight: 10 } + - { identifier: docs-start, parent: docs, name: 开始, pageRef: /docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } } + - { identifier: docs-components, parent: docs, name: 组件, pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } } + - { identifier: docs-develop, parent: docs, name: 开发, pageRef: /docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } } + - { identifier: docs-operate, parent: docs, name: 运维, pageRef: /docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } } + - { identifier: docs-reference, parent: docs, name: 参考, pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } } - { identifier: download, name: 下载, pageRef: /docs/download/download, weight: 20 } - { identifier: blog, name: 博客, pageRef: /blog, weight: 30 } - { identifier: community, name: 社区, pageRef: /community, weight: 40 } @@ -86,6 +96,7 @@ outputs: params: description: Apache HugeGraph is a full-stack graph database ecosystem for OLTP, OLAP, and graph AI. + images: [/img/social/hugegraph-default.png] github_repo: https://github.com/apache/hugegraph-doc github_project_repo: https://github.com/apache/hugegraph github_branch: master @@ -102,12 +113,22 @@ params: offline_search_index: summary offline_search_summary_length: 70 offline_search_max_results: 10 + # Optional enhancement: keep disabled until both reviewed latest-only Kapa + # source groups and the staging CSP/corpus acceptance gates pass. + ai_search: + enabled: false + provider: kapa + website_id: 0b277570-4740-451e-96fa-1e4ac1ac5e88 + source_groups: + en: '' + cn: '' footer_center_info: '' # The compact ASF footer owns the legal line; global controls stay in header. copyright: false print: toc: true ui: + theme_color: '#532fc9' dark_mode: enable: true show_menu: true @@ -120,6 +141,8 @@ params: wide_nav_sections: [community] sidebar_icon_policy: groups sidebar_item_overflow: wrap + backlinks: true + image_zoom: true page_context_menu: enable: true assistant_links: false diff --git a/layouts/_partials/ai/config.html b/layouts/_partials/ai/config.html new file mode 100644 index 000000000..4a0217381 --- /dev/null +++ b/layouts/_partials/ai/config.html @@ -0,0 +1,33 @@ +{{- $raw := .Site.Params.ai_search | default dict -}} +{{- $enabled := false -}} +{{- $provider := "" -}} +{{- $websiteID := "" -}} +{{- $sourceGroups := dict -}} +{{- if reflect.IsMap $raw -}} + {{- $enabled = index $raw "enabled" | default false -}} + {{- if ne (printf "%T" $enabled) "bool" -}} + {{- errorf "params.ai_search.enabled must be a boolean" -}} + {{- end -}} + {{- $provider = index $raw "provider" | default "" -}} + {{- $websiteID = index $raw "website_id" | default "" -}} + {{- $sourceGroups = index $raw "source_groups" | default dict -}} +{{- else -}} + {{- errorf "params.ai_search must be a map" -}} +{{- end -}} +{{- $enGroup := "" -}} +{{- $cnGroup := "" -}} +{{- if reflect.IsMap $sourceGroups -}} + {{- $enGroup = index $sourceGroups "en" | default "" -}} + {{- $cnGroup = index $sourceGroups "cn" | default "" -}} +{{- end -}} +{{- if $enabled -}} + {{- if ne $provider "kapa" }}{{ errorf "params.ai_search.provider must be kapa when AI search is enabled" }}{{ end -}} + {{- if not $websiteID }}{{ errorf "params.ai_search.website_id is required when AI search is enabled" }}{{ end -}} + {{- if or (not $enGroup) (not $cnGroup) }}{{ errorf "params.ai_search.source_groups.en and .cn are required when AI search is enabled" }}{{ end -}} +{{- end -}} +{{- return (dict + "enabled" $enabled + "provider" $provider + "websiteID" $websiteID + "sourceGroups" (dict "en" $enGroup "cn" $cnGroup) +) -}} diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html new file mode 100644 index 000000000..7866c8f25 --- /dev/null +++ b/layouts/_partials/hooks/body-end.html @@ -0,0 +1,41 @@ +{{- $shellConfig := dict + "version" (.Site.Params.version | default "latest") + "locale" .Site.Language.Lang +-}} +<script type="application/json" id="hg-shell-config">{{ $shellConfig | jsonify | safeJS }}</script> +{{- $shell := resources.Get "js/hugegraph-shell.js" -}} +{{- if hugo.IsProduction }}{{ $shell = $shell | minify | fingerprint }}{{ end }} +<script src="{{ $shell.RelPermalink }}" + {{- with $shell.Data.Integrity }} integrity="{{ . }}" crossorigin="anonymous"{{ end }}></script> + +{{- $ai := partial "ai/config.html" . -}} +{{- if $ai.enabled -}} + {{- $lang := .Site.Language.Lang -}} + {{- $sourceGroup := index $ai.sourceGroups $lang -}} + {{- $historical := ne (.Site.Params.version | default "latest") "latest" -}} + {{- $labels := cond (eq $lang "cn") + (dict "ask" "询问 AI" "description" "由 Kapa 提供;仅发送你的问题。" "latest" "回答基于 latest 文档" "retry" "重试" "error" "AI 暂时不可用,本地搜索不受影响。") + (dict "ask" "Ask AI" "description" "Powered by Kapa; only your question is sent." "latest" "Answers use the latest documentation" "retry" "Retry" "error" "AI is temporarily unavailable. Local search is unaffected.") + -}} + {{- $clientConfig := dict + "websiteId" $ai.websiteID + "sourceGroupId" $sourceGroup + "locale" (cond (eq $lang "cn") "zh" "en") + "historical" $historical + "labels" $labels + -}} +<script type="application/json" id="hg-ai-config">{{ $clientConfig | jsonify | safeJS }}</script> +<button type="button" class="hg-ask-ai-launcher d-print-none" data-hg-ask-ai + data-hg-ai-submit="false" aria-describedby="hg-ai-disclosure"> + <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> + <span>{{ index $labels "ask" }}</span> +</button> +<p class="visually-hidden" id="hg-ai-disclosure"> + {{ index $labels "description" }}{{ if $historical }} {{ index $labels "latest" }}.{{ end }} +</p> +<div class="hg-ai-status visually-hidden" role="status" aria-live="polite" data-hg-ai-status></div> + {{- $adapter := resources.Get "js/kapa-adapter.js" -}} + {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint }}{{ end }} +<script src="{{ $adapter.RelPermalink }}" + {{- with $adapter.Data.Integrity }} integrity="{{ . }}" crossorigin="anonymous"{{ end }}></script> +{{- end -}} diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html new file mode 100644 index 000000000..43baeb1fb --- /dev/null +++ b/layouts/_partials/hooks/head-end.html @@ -0,0 +1,5 @@ +{{- $themeColor := index .Site.Params.ui "theme_color" | default "" -}} +{{- if not (findRE `^#[0-9a-fA-F]{6}$` $themeColor) -}} + {{- errorf "params.ui.theme_color must be a six-digit hexadecimal color" -}} +{{- end }} +<style>:root { --hg-theme-color: {{ $themeColor | safeCSS }}; }</style> diff --git a/layouts/_partials/navbar-item.html b/layouts/_partials/navbar-item.html index a3f2dec9d..d07dd32d2 100644 --- a/layouts/_partials/navbar-item.html +++ b/layouts/_partials/navbar-item.html @@ -6,7 +6,6 @@ {{- $mode := .mode -}} {{- $index := .index -}} {{- $hasChildren := $entry.HasChildren -}} -{{- $hasVersionLinks := and (eq $entry.Identifier "docs") (gt (len ($page.Site.Params.versions | default slice)) 0) -}} {{- $taxonomyPage := false -}} {{- with $entry.Page -}} {{- if eq .Kind "taxonomy" }}{{ $taxonomyPage = . }}{{ end -}} @@ -28,7 +27,7 @@ {{- end -}} {{- end -}} {{- end -}} -{{- $hasPanel := or $hasChildren $taxonomyPage $hasVersionLinks -}} +{{- $hasPanel := or $hasChildren $taxonomyPage -}} {{- $key := $entry.Identifier | default $entry.Name | urlize -}} {{- $panelID := printf "td-navbar-%s-%s-%d" $mode $key $index -}} @@ -53,19 +52,6 @@ {{ partialCached "navbar-taxonomy-tags.html" (dict "page" $page "taxonomyPage" $taxonomyPage) $page.Site.Language.Lang $taxonomyPage.RelPermalink }} - {{- else if $hasVersionLinks -}} - {{- range $page.Site.Params.versions -}} - {{- $url := strings.TrimSuffix "/" (.url | default "") -}} - {{- if $url -}} - {{- $isActive := eq .version $page.Site.Params.version -}} - <a class="td-nav-menu__item-link{{ if $isActive }} active{{ end }}" href="{{ $url }}/" - data-td-navbar-level="1" data-td-navbar-kind="version" - {{- if $isActive }} aria-current="page"{{ end }}> - <i class="fa-solid fa-code-branch" aria-hidden="true"></i> - <span class="td-navbar-entry__label">{{ .name | default .version }}</span> - </a> - {{- end -}} - {{- end -}} {{- else -}} {{ partial "navbar-group-items.html" (dict "page" $page "items" $entry.Children "mode" "desktop" "top" $entry "depth" 1) }} {{- end }} diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html index bfcfca10e..852b21ecf 100644 --- a/layouts/_partials/navbar.html +++ b/layouts/_partials/navbar.html @@ -53,6 +53,13 @@ {{- /* Right zone: the search box leads as the elastic boundary before the fixed controls — version, language, theme, GitHub. */ -}} <div class="td-nav-links td-nav-util-zone"> + {{- if $drawerMode }} + <button class="td-nav-util hg-sidebar-restore" type="button" + 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/share/bar.html b/layouts/_partials/share/bar.html new file mode 100644 index 000000000..39e0820fa --- /dev/null +++ b/layouts/_partials/share/bar.html @@ -0,0 +1,13 @@ +{{- if and .IsPage (eq .Section "blog") -}} +<section class="td-share d-print-none" data-td-share data-td-page-context + data-td-t-copied="{{ T "ui_copy_link_success" }}" + data-td-t-copy-error="{{ T "ui_copy_link_error" }}"> + <div class="td-share__items" role="group" aria-label="{{ T "ui_share" }}"> + <button type="button" class="td-share__item td-share__item--copy" + data-td-action="copy_link" data-td-action-url="{{ .Permalink }}"> + <span class="td-share__icon"><i class="fa-solid fa-link" aria-hidden="true"></i></span> + <span>{{ T "ui_copy_link" }}</span> + </button> + </div> +</section> +{{- end -}} diff --git a/static/img/social/hugegraph-default.png b/static/img/social/hugegraph-default.png new file mode 100644 index 000000000..4e3e3a0c1 Binary files /dev/null and b/static/img/social/hugegraph-default.png differ diff --git a/tests/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs new file mode 100644 index 000000000..f11f5b6db --- /dev/null +++ b/tests/ui-ai/kapa-adapter.test.cjs @@ -0,0 +1,147 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const adapter = require('../../assets/js/kapa-adapter.js'); + +function harness() { + const calls = []; + const timers = new Map(); + let nextTimer = 1; + let onRender = null; + const trigger = { + dataset: {}, + disabled: false, + attrs: {}, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, + focus() { this.focused = true; }, + }; + const status = { + textContent: '', + classList: { toggle() {} }, + }; + const script = {}; + const documentObject = { + activeElement: trigger, + querySelector(selector) { + if (selector === '[data-hg-ai-status]') return status; + if (selector === 'script[data-hg-kapa-widget]') return script; + return null; + }, + querySelectorAll(selector) { + return selector === '[data-hg-ask-ai]' ? [trigger] : []; + }, + }; + const windowObject = { + Kapa(method, value) { + calls.push([method, value]); + if (method === 'render') onRender = value.onRender; + }, + setTimeout(callback) { + const id = nextTimer++; + timers.set(id, callback); + return id; + }, + clearTimeout(id) { timers.delete(id); }, + }; + const config = { + websiteId: 'website', + sourceGroupId: 'source-en', + locale: 'en', + labels: { error: 'unavailable' }, + }; + return { + calls, + config, + documentObject, + fireRender() { onRender(); }, + fireTimeout() { Array.from(timers.values()).forEach((callback) => callback()); }, + trigger, + windowObject, + }; +} + +test('uses one fixed bundle and explicit privacy-safe widget settings', () => { + assert.equal( + adapter.BUNDLE_URL, + 'https://widget.kapa.ai/kapa-widget.bundle.js', + ); + const attrs = adapter.scriptAttributes({ + websiteId: 'website', + sourceGroupId: 'source-cn', + locale: 'zh', + }); + assert.equal(attrs['data-render-on-load'], 'false'); + assert.equal(attrs['data-launcher-button-hidden'], 'true'); + assert.equal(attrs['data-search-mode-enabled'], 'false'); + assert.equal(attrs['data-modal-open-on-command-k'], 'false'); + assert.equal(attrs['data-consent-required'], 'false'); + assert.equal(attrs['data-user-analytics-cookie-enabled'], 'false'); + assert.equal(attrs['data-user-analytics-fingerprint-enabled'], 'false'); + assert.equal(attrs['data-bot-protection-mechanism'], 'hcaptcha'); + assert.equal(attrs['data-source-group-ids-include'], 'source-cn'); +}); + +test('sends only the trimmed query after explicit activation and render', () => { + const h = harness(); + const controller = adapter.createController( + h.windowObject, + h.documentObject, + h.config, + ); + assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose']); + + controller.activate(' how to start? ', true, h.trigger); + assert.equal(controller.getState(), 'loading'); + assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose', 'render']); + + h.fireRender(); + assert.equal(controller.getState(), 'ready'); + assert.deepEqual(h.calls.slice(-2), [ + ['setSourceGroupIDs', ['source-en']], + ['open', { mode: 'ai', query: 'how to start?', submit: true }], + ]); + assert.equal( + JSON.stringify(h.calls).includes('http'), + false, + 'no page URL is passed to Kapa', + ); +}); + +test('ignores duplicate activation and never opens after a late render', () => { + const h = harness(); + const controller = adapter.createController( + h.windowObject, + h.documentObject, + h.config, + ); + controller.activate('first', true, h.trigger); + controller.activate('second', true, h.trigger); + assert.equal( + h.calls.filter(([name]) => name === 'render').length, + 1, + ); + + h.fireTimeout(); + assert.equal(controller.getState(), 'error'); + h.fireRender(); + assert.equal( + h.calls.filter(([name]) => name === 'open').length, + 0, + ); +}); + +test('launcher opens a blank session without auto-submit', () => { + const h = harness(); + const controller = adapter.createController( + h.windowObject, + h.documentObject, + h.config, + ); + controller.activate('', false, h.trigger); + h.fireRender(); + assert.deepEqual(h.calls.at(-1), [ + 'open', + { mode: 'ai', query: '', submit: false }, + ]); +}); diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs new file mode 100644 index 000000000..1a18b8de3 --- /dev/null +++ b/tests/ui-ai/ui-contract.test.cjs @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '../..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('disabled AI emits no widget or adapter markup', () => { + const config = read('hugo.yaml'); + const hook = read('layouts/_partials/hooks/body-end.html'); + assert.match(config, /ai_search:\n\s+enabled: false/); + assert.match(hook, /\{\{- if \$ai\.enabled -\}\}/); + assert.equal(config.includes('widget.kapa.ai'), false); +}); + +test('theme color and social fallback have one configuration authority', () => { + const config = read('hugo.yaml'); + const css = read('assets/scss/_styles_project.scss'); + assert.match(config, /theme_color: '#532fc9'/); + assert.match(config, /images: \[\/img\/social\/hugegraph-default\.png\]/); + assert.equal(css.includes('$hg-navbar-purple'), false); + assert.equal(css.includes('#532fc9'), false); +}); + +test('documentation menu has five groups and no duplicate version panel', () => { + const config = read('hugo.yaml'); + const navbarItem = read('layouts/_partials/navbar-item.html'); + for (const id of [ + 'docs-start', + 'docs-components', + 'docs-develop', + 'docs-operate', + 'docs-reference', + ]) { + assert.match(config, new RegExp(`identifier: ${id}`)); + } + assert.equal(navbarItem.includes('$hasVersionLinks'), false); +}); + +test('shell persistence uses the version and locale scoped key', () => { + const source = read('assets/js/hugegraph-shell.js'); + assert.match(source, /oink\.sidebar\.v1\./); + assert.match(source, /config\.version/); + assert.match(source, /config\.locale/); + assert.match(source, /sidebar\.inert = isolated/); +});
