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

{{ $version }}

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

{{ T "download_asf_note" }}

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

{{ $copy.title }}

+

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

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

{{ index $copy $role }}

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

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

+ + +

{{ T "ui_ai_consent_description" }}

+ {{ if $historical }}

{{ index $labels "latest" }}.

{{ end }} +

{{ T "ui_ai_privacy" }}

+ +
{{- $adapter := resources.Get "js/kapa-adapter.js" -}} {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint }}{{ end }} diff --git a/layouts/_partials/landing/sections/community-members.html b/layouts/_partials/landing/sections/community-members.html new file mode 100644 index 0000000000..2e1abd08b0 --- /dev/null +++ b/layouts/_partials/landing/sections/community-members.html @@ -0,0 +1 @@ +{{- partial "community/members.html" (dict "page" .page) -}} diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html index 0f7d8ab88c..9eeb35de01 100644 --- a/layouts/_partials/navbar.html +++ b/layouts/_partials/navbar.html @@ -77,23 +77,7 @@ {{- partialCached "shell/icon.html" "code-branch" "navbar-version-code-branch" -}}
- {{- range . }} - {{- if eq .name "---" }} -
- {{- else }} - {{- $url := strings.TrimSuffix "/" (.url | default "") }} - {{- $isActive := or (eq .version $.Site.Params.version) (eq (strings.TrimSuffix "/" $.Site.BaseURL) $url) }} - {{- if $url }} - {{- partial "version-link.html" (dict - "page" $ "version" . "active" $isActive "icon" false - "class" (printf "td-nav-hover-menu__option%s" (cond $isActive " td-is-active" "")) - "iconKey" (printf "navbar-version-%s" .version) - ) -}} - {{- else }} - {{ .name | default .version | markdownify }} - {{- end }} - {{- end }} - {{- end }} + {{ partial "version-menu-links.html" (dict "page" $ "versions" . "class" "td-nav-hover-menu__option" "icon" false "key" "navbar") }}
{{- end }} @@ -168,16 +152,7 @@ {{- else if and (eq .Identifier "docs") $.Site.Params.versions }}
- {{- range $.Site.Params.versions }} - {{- $url := strings.TrimSuffix "/" (.url | default "") -}} - {{- if $url }} - {{- partial "version-link.html" (dict - "page" $ "version" . "active" (eq .version $.Site.Params.version) - "class" "td-site-nav__menu-link td-site-nav__menu-child" - "iconKey" (printf "landing-mobile-version-%s" .version) - ) -}} - {{- end }} - {{- end }} + {{ partial "version-menu-links.html" (dict "page" $ "versions" $.Site.Params.versions "class" "td-site-nav__menu-link td-site-nav__menu-child" "key" "landing-mobile") }}
{{- end }} {{- end }} diff --git a/layouts/_partials/shell/sidebar-panel.html b/layouts/_partials/shell/sidebar-panel.html index a433197ba4..e4a57af25e 100644 --- a/layouts/_partials/shell/sidebar-panel.html +++ b/layouts/_partials/shell/sidebar-panel.html @@ -63,15 +63,7 @@
{{ $.Site.Params.version_menu | default "Version" }}
{{- end }} diff --git a/layouts/_partials/version-menu-links.html b/layouts/_partials/version-menu-links.html new file mode 100644 index 0000000000..6f3bf8117b --- /dev/null +++ b/layouts/_partials/version-menu-links.html @@ -0,0 +1,17 @@ +{{- /* Presentation only: all version targets remain in the document. Native + details provides pointer, touch and keyboard access without a JS dependency. */ -}} +{{- $p := .page -}} +{{- $links := where .versions "url" "ne" "" -}} +{{- range $index, $version := $links -}} + {{- if eq $index 3 }} +
+ … + {{- end }} + {{- $active := eq $version.version $p.Site.Params.version -}} + {{- partial "version-link.html" (dict + "page" $p "version" $version "active" $active "icon" $.icon + "class" (printf "%s%s" $.class (cond $active " td-is-active" "")) + "iconKey" (printf "%s-version-%s" $.key $version.version) + ) -}} +{{- end -}} +{{- if gt (len $links) 3 }}
{{ end -}} diff --git a/layouts/_shortcodes/asf-downloads.html b/layouts/_shortcodes/asf-downloads.html new file mode 100644 index 0000000000..f12b800481 --- /dev/null +++ b/layouts/_shortcodes/asf-downloads.html @@ -0,0 +1,8 @@ +{{- /* {{< asf-downloads latest >}} or {{< asf-downloads archived >}}: + ASF release download tables driven by data/downloads/asf.json. The + positional mode is required; a missing mode fails the build instead of + rendering duplicate sections. */ -}} +{{- if or .IsNamedParams (ne (len .Params) 1) -}} + {{- errorf "asf-downloads requires exactly one positional mode (latest or archived) at %s" .Position -}} +{{- end -}} +{{- partial "asf-downloads.html" (dict "page" .Page "mode" (.Get 0) "position" .Position) -}} diff --git a/layouts/community/landing.md b/layouts/community/landing.md new file mode 100644 index 0000000000..e884af8fe5 --- /dev/null +++ b/layouts/community/landing.md @@ -0,0 +1,29 @@ +{{- /* + Community Markdown follows the landing data order. Native OINK sections use + its text renderer unchanged; only the nested roster uses the site partial. +*/ -}} +{{- .Store.Set "tdOutputFormat" "markdown" -}} +# {{ .Title }} + +{{ with .Description }} +> {{ . }} + +{{ end }} + +{{ $page := . -}} +{{- $landing := partial "landing/data.html" . -}} +{{- $chunks := slice -}} +{{- range $entry := ($landing.sections | default slice) -}} + {{- $resolved := partial "landing/entry.html" (dict "home" $landing "entry" $entry) -}} + {{- if and $resolved.enabled $resolved.data -}} + {{- if eq $resolved.type "community-members" -}} + {{- $chunks = $chunks | append (partial "community/members.md" (dict "page" $page) | strings.TrimSpace) -}} + {{- else -}} + {{- $text := partial "landing/text.html" (dict "page" $page "data" (dict "sections" (slice $entry))) | strings.TrimSpace -}} + {{- with $text }} + {{- $chunks = $chunks | append . -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{ delimit $chunks "\n\n" | safeHTML }} diff --git a/scripts/community_roster.md b/scripts/community_roster.md new file mode 100644 index 0000000000..5b78afc3db --- /dev/null +++ b/scripts/community_roster.md @@ -0,0 +1,41 @@ +# Community roster data + +`roster.json` is the checked-in, visitor-facing snapshot of current Apache +HugeGraph PMC members and Committers. It is generated from the three ASF public +sources recorded in the file: + +```bash +python3 scripts/community_roster.py refresh +python3 scripts/community_roster.py validate --warn-after-days 90 +``` + +`refresh` is a maintainer-run operation. It finishes all source, role, mapping, +and avatar checks before atomically replacing the last-good roster. It never +pushes or opens a pull request. + +`github-map.json` is deliberately maintained by human review. A mapping must +record both the exact GitHub login and the account's numeric GitHub user ID. +Do not derive mappings from a person's name, email address, employer, or commit +history. Leave an ASF ID unmapped until a maintainer has confirmed the account. + +Mapped avatars are downloaded during refresh, converted with `cwebp` when +needed, stripped of metadata, checked as 128 by 128 WebP, and stored under a +SHA-256 content-addressed filename. Unmapped members render initials as static +cards without requiring JavaScript. + +The fixed `validate` command is fully offline and checks checked-in data, +identity rules, and local avatar files. Render the site separately, then opt in +to artifact checks: + +```bash +hugo --destination /safe/prebuilt/site +python3 scripts/community_roster.py validate \ + --warn-after-days 90 \ + --artifact /safe/prebuilt/site +``` + +The artifact option never invokes Hugo or downloads modules itself. + +Once the verified assets and roster are published, removal of an unreferenced +old avatar is best effort. A cleanup failure emits an Actions warning but does +not invalidate or roll back the complete new bundle. diff --git a/scripts/community_roster.py b/scripts/community_roster.py new file mode 100644 index 0000000000..c3c2ec883c --- /dev/null +++ b/scripts/community_roster.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 +"""Refresh and validate the offline Apache HugeGraph community roster.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import html.parser +import json +import os +import pathlib +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DATA_DIR = ROOT / "data" / "community" +ROSTER_PATH = DATA_DIR / "roster.json" +MAP_PATH = DATA_DIR / "github-map.json" +AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars" +PROJECT = "hugegraph" +SCHEMA_VERSION = 1 +SOURCES = { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", +} +JSON_LIMIT = 16 * 1024 * 1024 +AVATAR_LIMIT = 5 * 1024 * 1024 +ASF_ID_PATTERN = re.compile(r"^[a-z][a-z0-9._-]*$") +GITHUB_LOGIN_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") +AVATAR_PATH_PATTERN = re.compile(r"^/img/community/avatars/([0-9a-f]{64})\.webp$") + + +class RosterError(ValueError): + pass + + +def _read_json(path: pathlib.Path) -> dict: + try: + result = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RosterError(f"{path}: invalid JSON: {exc}") from exc + if not isinstance(result, dict): + raise RosterError(f"{path}: JSON root must be an object") + return result + + +def _validate_remote_url(url: str, expected_hosts: set[str], kind: str) -> None: + try: + parsed = urllib.parse.urlparse(url) + port = parsed.port + except ValueError as exc: + raise RosterError(f"{kind}: malformed URL: {url}") from exc + if ( + parsed.scheme != "https" + or parsed.hostname not in expected_hosts + or port not in (None, 443) + or parsed.username is not None + or parsed.password is not None + ): + raise RosterError(f"{kind}: URL is not allowlisted: {url}") + + +class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, expected_hosts: set[str], kind: str): + super().__init__() + self.expected_hosts = expected_hosts + self.kind = kind + + def redirect_request(self, request, fp, code, msg, headers, newurl): + _validate_remote_url(newurl, self.expected_hosts, self.kind) + return super().redirect_request(request, fp, code, msg, headers, newurl) + + +def _open_allowlisted(request: urllib.request.Request, expected_hosts: set[str], kind: str): + _validate_remote_url(request.full_url, expected_hosts, kind) + opener = urllib.request.build_opener(_AllowlistedRedirectHandler(expected_hosts, kind)) + return opener.open(request, timeout=30) + + +def _read_bounded_response(response, *, expected_hosts: set[str], content_types: set[str], limit: int, kind: str) -> bytes: + final_url = response.geturl() + _validate_remote_url(final_url, expected_hosts, kind) + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + if content_type not in content_types and not (kind == "JSON source" and content_type.endswith("+json")): + raise RosterError(f"{kind}: unsupported Content-Type {content_type!r}") + raw = response.read(limit + 1) + if len(raw) > limit: + raise RosterError(f"{kind}: response exceeds {limit} bytes") + return raw + + +def _fetch_json(url: str) -> dict: + request = urllib.request.Request(url, headers={"User-Agent": "apache-hugegraph-doc-community-roster/1"}) + with _open_allowlisted(request, {"whimsy.apache.org"}, "JSON source") as response: + if response.status != 200: + raise RosterError(f"{url}: HTTP {response.status}") + raw = _read_bounded_response( + response, + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=JSON_LIMIT, + kind="JSON source", + ) + try: + result = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RosterError(f"{url}: malformed JSON: {exc}") from exc + if not isinstance(result, dict): + raise RosterError(f"{url}: JSON root must be an object") + return result + + +def _person_name(people: dict, asf_id: str) -> str: + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"invalid ASF ID {asf_id!r}") + records = people.get("people") + if not isinstance(records, dict): + raise RosterError("people source must contain a people object") + record = records.get(asf_id) + name = record.get("name") if isinstance(record, dict) else None + if isinstance(name, list): + name = name[0] if name else "" + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"people source has no public name for ASF ID {asf_id!r}") + return name.strip() + + +def _initials(name: str) -> str: + parts = [part for part in name.replace("-", " ").split() if part] + return "".join(part[0].upper() for part in parts[:2]) or "?" + + +def _sort_key(asf_id: str, names: dict[str, str]) -> tuple[str, str]: + return names[asf_id].casefold(), asf_id.casefold() + + +def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict: + if data.get("schema_version") != SCHEMA_VERSION: + raise RosterError("github-map.json: schema_version must be 1") + mappings = data.get("mappings") + if not isinstance(mappings, dict): + raise RosterError("github-map.json: mappings must be an object") + public_names = data.get("public_names", {}) + if not isinstance(public_names, dict): + raise RosterError("github-map.json: public_names must be an object") + for asf_id, public_name in public_names.items(): + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"github-map.json: invalid public-name ASF ID {asf_id!r}") + if roster_ids is not None and asf_id not in roster_ids: + raise RosterError(f"github-map.json: unknown public-name ASF ID {asf_id!r}") + if not isinstance(public_name, str) or not public_name.strip(): + raise RosterError(f"github-map.json: public name for {asf_id!r} must be non-empty") + display_order = data.get("display_order", {}) + if not isinstance(display_order, dict): + raise RosterError("github-map.json: display_order must be an object") + for role, ordered_ids in display_order.items(): + if role not in {"pmc", "committers"} or not isinstance(ordered_ids, list): + raise RosterError("github-map.json: display_order must contain PMC/Committers arrays") + if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in ordered_ids): + raise RosterError(f"github-map.json: display_order.{role} contains an invalid ASF ID") + if len(ordered_ids) != len(set(ordered_ids)): + raise RosterError(f"github-map.json: display_order.{role} contains duplicate ASF IDs") + if roster_ids is not None and any(asf_id not in roster_ids for asf_id in ordered_ids): + raise RosterError(f"github-map.json: display_order.{role} contains an unknown ASF ID") + logins: set[str] = set() + user_ids: set[int] = set() + for asf_id, mapping in mappings.items(): + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"github-map.json: invalid ASF ID {asf_id!r}") + if roster_ids is not None and asf_id not in roster_ids: + raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}") + if not isinstance(mapping, dict): + raise RosterError(f"github-map.json: mapping for {asf_id!r} must be an object") + login, user_id = mapping.get("login"), mapping.get("user_id") + if not isinstance(login, str) or not login.strip() or login != login.strip(): + raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed login") + if not GITHUB_LOGIN_PATTERN.fullmatch(login) or "--" in login: + raise RosterError(f"github-map.json: invalid GitHub login {login!r}") + if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0: + raise RosterError(f"github-map.json: {asf_id!r} needs a positive numeric user_id") + if login.casefold() in logins: + raise RosterError(f"github-map.json: duplicate GitHub login {login!r}") + if user_id in user_ids: + raise RosterError(f"github-map.json: duplicate GitHub user_id {user_id}") + logins.add(login.casefold()) + user_ids.add(user_id) + return mappings + + +def _ordered_ids(ids: set[str], names: dict[str, str], mapping_data: dict, role: str) -> list[str]: + configured = mapping_data.get("display_order", {}).get(role, []) + if any(asf_id not in ids for asf_id in configured): + raise RosterError(f"github-map.json: display_order.{role} contains an ID outside its role") + configured_set = set(configured) + return list(configured) + sorted(ids - configured_set, key=lambda item: _sort_key(item, names)) + + +def _webp_dimensions(raw: bytes) -> tuple[int, int]: + if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + chunk = raw[12:16] + if chunk == b"VP8X": + return 1 + int.from_bytes(raw[24:27], "little"), 1 + int.from_bytes(raw[27:30], "little") + if chunk == b"VP8L": + bits = int.from_bytes(raw[21:25], "little") + return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF) + if chunk == b"VP8 ": + marker = raw.find(b"\x9d\x01\x2a", 20) + if marker < 0 or marker + 7 > len(raw): + raise RosterError("avatar has an invalid VP8 frame") + width, height = struct.unpack_from(" list[bytes]: + if len(raw) < 20 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + if int.from_bytes(raw[4:8], "little") != len(raw) - 8: + raise RosterError("avatar has an invalid RIFF length") + kinds: list[bytes] = [] + cursor = 12 + while cursor + 8 <= len(raw): + kind = raw[cursor : cursor + 4] + size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little") + cursor += 8 + size + (size % 2) + if cursor > len(raw): + raise RosterError("avatar has a truncated WebP chunk") + kinds.append(kind) + if cursor != len(raw): + raise RosterError("avatar has trailing WebP data") + return kinds + + +def _validate_webp(raw: bytes, expected_dimensions: tuple[int, int] | None = None) -> tuple[int, int]: + dimensions = _webp_dimensions(raw) + kinds = _webp_chunk_kinds(raw) + if not any(kind in {b"VP8 ", b"VP8L"} for kind in kinds): + raise RosterError("avatar has no decodable WebP image bitstream") + if any(kind in {b"EXIF", b"XMP ", b"ICCP"} for kind in kinds): + raise RosterError("avatar contains metadata") + if expected_dimensions and dimensions != expected_dimensions: + raise RosterError(f"avatar dimensions are {dimensions}, expected {expected_dimensions}") + decoder = shutil.which("dwebp") + if not decoder: + raise RosterError("validating mapped avatars requires dwebp") + with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-decode-") as work: + source = pathlib.Path(work) / "avatar.webp" + target = pathlib.Path(work) / "avatar.ppm" + source.write_bytes(raw) + try: + result = subprocess.run( + [decoder, str(source), "-o", str(target)], + text=True, + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RosterError(f"dwebp could not decode avatar: {exc}") from exc + if result.returncode or not target.is_file(): + raise RosterError(f"dwebp rejected avatar: {result.stderr.strip()}") + return dimensions + + +def _strip_webp_metadata(raw: bytes) -> bytes: + """Remove optional metadata chunks while preserving the image bitstream.""" + _webp_dimensions(raw) + chunks: list[bytes] = [] + cursor = 12 + while cursor + 8 <= len(raw): + kind = raw[cursor : cursor + 4] + size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little") + end = cursor + 8 + size + (size % 2) + if end > len(raw): + raise RosterError("avatar has a truncated WebP chunk") + chunk = bytearray(raw[cursor:end]) + if kind not in {b"EXIF", b"XMP ", b"ICCP"}: + if kind == b"VP8X": + chunk[8] &= ~0x2D + chunks.append(bytes(chunk)) + cursor = end + if cursor != len(raw): + raise RosterError("avatar has trailing WebP data") + payload = b"WEBP" + b"".join(chunks) + return b"RIFF" + len(payload).to_bytes(4, "little") + payload + + +def _avatar_bytes(user_id: int) -> bytes: + request = urllib.request.Request( + f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4", + headers={"Accept": "image/webp", "User-Agent": "apache-hugegraph-doc-community-roster/1"}, + ) + with _open_allowlisted(request, {"avatars.githubusercontent.com"}, "GitHub avatar") as response: + raw = _read_bounded_response( + response, + expected_hosts={"avatars.githubusercontent.com"}, + content_types={"image/png", "image/jpeg", "image/webp"}, + limit=AVATAR_LIMIT, + kind="GitHub avatar", + ) + try: + raw = _strip_webp_metadata(raw) + except RosterError: + converter = shutil.which("cwebp") + if not converter: + raise RosterError("mapped avatars require cwebp when GitHub does not return WebP") + with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-") as work: + source = pathlib.Path(work) / "source" + target = pathlib.Path(work) / "avatar.webp" + source.write_bytes(raw) + try: + result = subprocess.run( + [converter, "-quiet", "-resize", "128", "128", "-metadata", "none", str(source), "-o", str(target)], + text=True, + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {exc}") from exc + if result.returncode: + raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {result.stderr.strip()}") + raw = _strip_webp_metadata(target.read_bytes()) + _validate_webp(raw, expected_dimensions=(128, 128)) + return raw + + +def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict: + member = { + "asf_id": asf_id, + "name": name, + "initials": _initials(name), + "chair": chair, + "profile_url": f"https://people.apache.org/phonebook.html?uid={asf_id}", + } + if mapping: + member["github"] = {"login": mapping["login"], "user_id": mapping["user_id"]} + return member + + +def build_roster(committee_data: dict, projects_data: dict, people_data: dict, mapping_data: dict) -> dict: + projects = projects_data.get("projects") + committees = committee_data.get("committees") + if not isinstance(projects, dict) or not isinstance(committees, dict): + raise RosterError("ASF sources must contain projects and committees objects") + project = projects.get(PROJECT) + committee = committees.get(PROJECT) + if not isinstance(project, dict) or not isinstance(committee, dict): + raise RosterError("ASF sources do not contain the HugeGraph project") + owners, members = project.get("owners"), project.get("members") + chair_map, committee_roster = committee.get("chair"), committee.get("roster") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("LDAP project owners/members must be arrays") + if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in owners + members): + raise RosterError("LDAP project owners/members contain an invalid ASF ID") + for field, asf_ids in (("owners", owners), ("members", members)): + if len(asf_ids) != len(set(asf_ids)): + raise RosterError(f"LDAP project {field} contains duplicate ASF IDs") + if not isinstance(chair_map, dict) or len(chair_map) != 1: + raise RosterError("committee source must name exactly one Chair") + if not isinstance(committee_roster, dict): + raise RosterError("committee roster must be an object") + if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in [*chair_map, *committee_roster]): + raise RosterError("committee source contains an invalid ASF ID") + owner_ids, member_ids = set(owners), set(members) + chair = next(iter(chair_map)) + if not owner_ids <= member_ids: + raise RosterError("LDAP owners must be a subset of members") + if chair not in owner_ids: + raise RosterError("Chair must be an LDAP owner") + if owner_ids != set(committee_roster): + raise RosterError("committee roster and LDAP owners disagree") + mappings = _validate_mapping(mapping_data, member_ids) + names = {asf_id: _person_name(people_data, asf_id) for asf_id in member_ids} + names.update({asf_id: mapping["login"] for asf_id, mapping in mappings.items()}) + names.update({asf_id: public_name for asf_id, public_name in mapping_data.get("public_names", {}).items()}) + pmc_ids = [chair] + _ordered_ids(owner_ids - {chair}, names, mapping_data, "pmc") + committer_ids = _ordered_ids(member_ids - owner_ids, names, mapping_data, "committers") + return { + "schema_version": SCHEMA_VERSION, + "project": PROJECT, + "retrieved_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source": { + **SOURCES, + "chair": chair, + "owners": sorted(owner_ids), + "members": sorted(member_ids), + }, + "roles": { + "pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in pmc_ids], + "committers": [_member(i, names[i], False, mappings.get(i)) for i in committer_ids], + }, + } + + +def _install_avatars(candidate: dict, target: pathlib.Path) -> None: + target.mkdir(parents=True, exist_ok=True) + for role in ("pmc", "committers"): + for member in candidate["roles"][role]: + github = member.get("github") + if not github: + continue + raw = _avatar_bytes(github["user_id"]) + digest = hashlib.sha256(raw).hexdigest() + path = target / f"{digest}.webp" + if not path.exists(): + path.write_bytes(raw) + member["avatar"] = f"/img/community/avatars/{path.name}" + member["profile_url"] = f"https://github.com/{github['login']}" + + +def validate_bundle(warn_after_days: int) -> list[str]: + _validate_repo_paths() + roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH) + if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") != PROJECT: + raise RosterError("roster.json: unsupported schema_version or project") + roles, source = roster.get("roles"), roster.get("source") + if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}: + raise RosterError("roster.json: roles must contain only pmc and committers") + if not isinstance(source, dict): + raise RosterError("roster.json: source must be an object") + for key, url in SOURCES.items(): + if source.get(key) != url: + raise RosterError(f"roster.json: source.{key} is not authoritative") + owners, members, chair = source.get("owners"), source.get("members"), source.get("chair") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("roster.json: source owners/members must be arrays") + if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in owners + members): + raise RosterError("roster.json: source owners/members contain an invalid ASF ID") + if owners != sorted(set(owners)) or members != sorted(set(members)): + raise RosterError("roster.json: source owners/members must be sorted and unique") + if not set(owners) <= set(members) or chair not in owners: + raise RosterError("roster.json: invalid owners/members/Chair relationship") + if not isinstance(chair, str) or not ASF_ID_PATTERN.fullmatch(chair): + raise RosterError("roster.json: source Chair must be a valid ASF ID") + pmc, committers = roles["pmc"], roles["committers"] + if not isinstance(pmc, list) or not isinstance(committers, list) or not pmc: + raise RosterError("roster.json: invalid role arrays") + people = pmc + committers + for person in people: + if not isinstance(person, dict): + raise RosterError("roster.json: every role entry must be an object") + asf_id = person.get("asf_id") + if not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError("roster.json: role entry has an invalid ASF ID") + name = person.get("name") + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"roster.json: member name must be non-empty for {asf_id!r}") + if person.get("initials") != _initials(name): + raise RosterError(f"roster.json: member initials mismatch for {asf_id!r}") + if not isinstance(person.get("profile_url"), str): + raise RosterError(f"roster.json: member profile URL must be a string for {asf_id!r}") + if type(person.get("chair")) is not bool: + raise RosterError(f"roster.json: chair must be boolean for {asf_id!r}") + ids = [person["asf_id"] for person in people] + if len(ids) != len(set(ids)) or set(ids) != set(members): + raise RosterError("roster.json: members must match unique source ASF IDs") + if {p["asf_id"] for p in pmc} != set(owners): + raise RosterError("roster.json: PMC must equal owners") + if {p["asf_id"] for p in committers} != set(members) - set(owners): + raise RosterError("roster.json: Committers must equal members minus owners") + chairs = [person for person in people if person.get("chair") is True] + if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != chairs[0]: + raise RosterError("roster.json: unique Chair must be first in PMC") + mappings = _validate_mapping(mapping, set(ids)) + names = {person["asf_id"]: person["name"] for person in people} + expected_pmc = [chair] + _ordered_ids(set(owners) - {chair}, names, mapping, "pmc") + expected_committers = _ordered_ids(set(members) - set(owners), names, mapping, "committers") + if [person["asf_id"] for person in pmc] != expected_pmc: + raise RosterError("roster.json: PMC order does not match display_order or sorted by public name") + if [person["asf_id"] for person in committers] != expected_committers: + raise RosterError("roster.json: Committers order does not match display_order or sorted by public name") + public_names = mapping.get("public_names", {}) + for person in people: + if person["asf_id"] in public_names and person["name"] != public_names[person["asf_id"]]: + raise RosterError(f"roster.json: public name drift for {person['asf_id']!r}") + expected, avatar = mappings.get(person["asf_id"]), person.get("avatar") + if expected != person.get("github"): + raise RosterError(f"roster.json: GitHub mapping drift for {person['asf_id']!r}") + if expected: + match = AVATAR_PATH_PATTERN.fullmatch(avatar) if isinstance(avatar, str) else None + if not match: + raise RosterError(f"roster.json: mapped member {person['asf_id']!r} needs a local avatar") + filename = f"{match.group(1)}.webp" + path = AVATAR_DIR / filename + if path.is_symlink(): + raise RosterError(f"roster.json: avatar must not be a symlink {avatar}") + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != match.group(1): + raise RosterError(f"roster.json: invalid avatar {avatar}") + _validate_webp(raw, expected_dimensions=(128, 128)) + if person["profile_url"] != f"https://github.com/{expected['login']}": + raise RosterError(f"roster.json: mapped profile URL mismatch") + elif avatar: + raise RosterError(f"roster.json: unmapped member has an avatar") + elif person["profile_url"] != f"https://people.apache.org/phonebook.html?uid={person['asf_id']}": + raise RosterError(f"roster.json: unmapped profile URL mismatch for {person['asf_id']!r}") + retrieved_at = roster.get("retrieved_at") + if not isinstance(retrieved_at, str): + raise RosterError("roster.json: retrieved_at must be an ISO-8601 UTC string") + try: + retrieved = dt.datetime.fromisoformat(retrieved_at.replace("Z", "+00:00")) + except (KeyError, TypeError, ValueError) as exc: + raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") from exc + now = dt.datetime.now(dt.timezone.utc) + if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5): + raise RosterError("roster.json: retrieved_at is in the future or lacks a timezone") + age = now - retrieved + return [f"community roster is {age.days} days old (threshold: {warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else [] + + +class _CommunityLinkParser(html.parser.HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.role_stack: list[str | None] = [] + self.section_order: list[str] = [] + self.links = {"pmc": [], "committers": []} + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if tag == "section": + role = attributes.get("data-community-role") + if role in self.links: + self.section_order.append(role) + self.role_stack.append(role if role in self.links else None) + elif tag == "a" and self.role_stack and self.role_stack[-1]: + href = attributes.get("href") + if href: + self.links[self.role_stack[-1]].append(href) + + def handle_endtag(self, tag: str) -> None: + if tag == "section" and self.role_stack: + self.role_stack.pop() + + +def _rendered_role_links(rendered: str, html_output: bool) -> dict[str, list[str]]: + if html_output: + parser = _CommunityLinkParser() + parser.feed(rendered) + if parser.section_order != ["pmc", "committers"]: + raise RosterError("Community role section order drift") + return parser.links + starts = {} + for role, heading in (("pmc", "PMC"), ("committers", "Committers")): + match = re.search(rf"(?m)^### {heading}\s*$", rendered) + if not match: + return {"pmc": [], "committers": []} + starts[role] = match.end() + if starts["pmc"] >= starts["committers"]: + return {"pmc": [], "committers": []} + tail = rendered[starts["committers"] :] + next_heading = re.search(r"(?m)^##\s", tail) + segments = { + "pmc": rendered[starts["pmc"] : starts["committers"]], + "committers": tail[: next_heading.start()] if next_heading else tail, + } + return { + role: re.findall(r"(?m)^-\s+\[[^\]]+\]\(([^)\s]+)\)", segment) + for role, segment in segments.items() + } + + +def validate_rendered_outputs(destination: pathlib.Path) -> None: + expected = { + "community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "community/index.md": ("## Project members", "### PMC", "### Committers"), + "cn/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"), + } + roster_roles = _read_json(ROSTER_PATH)["roles"] + for relative, markers in expected.items(): + path = destination / relative + if not path.is_file(): + raise RosterError(f"rendered output is missing {relative}") + rendered = path.read_text(encoding="utf-8") + if relative.endswith(".html"): + has_markers = all( + re.search(rf'data-community-role=(?:"{role}"|{role})(?:\s|>)', rendered) + for role in ("pmc", "committers") + ) + else: + has_markers = all(marker in rendered for marker in markers) + if not has_markers: + raise RosterError(f"rendered output {relative} is missing Community markers") + rendered_links = _rendered_role_links(rendered, relative.endswith(".html")) + for role, entries in roster_roles.items(): + # Only reviewed GitHub mappings are actionable links. Unmapped + # ASF members remain visible as static identity cards. + expected_links = [person["profile_url"] for person in entries if person.get("github")] + if rendered_links[role] != expected_links: + raise RosterError(f"rendered output {relative} has {role} link parity drift") + + +def _atomic_write(path: pathlib.Path, raw: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = pathlib.Path(temporary) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + +def _unlink(path: pathlib.Path) -> None: + path.unlink() + + +def _copy_candidate(raw: bytes, destination: pathlib.Path) -> None: + # An interrupted refresh may leave the temporary path behind. Remove only + # that exact path (including a symlink) before recreating it exclusively. + if destination.exists() or destination.is_symlink(): + destination.unlink() + with destination.open("xb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + + +def _validate_avatar_blob(name: str, raw: bytes) -> None: + match = re.fullmatch(r"([0-9a-f]{64})\.webp", name) + if not match or hashlib.sha256(raw).hexdigest() != match.group(1): + raise RosterError(f"candidate avatar name/hash mismatch: {name}") + _validate_webp(raw, expected_dimensions=(128, 128)) + + +def _assert_repo_path(path: pathlib.Path, label: str) -> None: + root = ROOT.absolute() + candidate = path.absolute() + try: + relative = candidate.relative_to(root) + except ValueError as exc: + raise RosterError(f"{label} must stay inside the repository") from exc + current = root + if current.is_symlink(): + raise RosterError("repository root must not be a symlink") + for part in relative.parts: + current /= part + if current.is_symlink(): + raise RosterError(f"{label} must not contain symlink path components") + try: + candidate.resolve(strict=False).relative_to(root.resolve(strict=True)) + except (OSError, ValueError) as exc: + raise RosterError(f"{label} resolves outside the repository") from exc + + +def _validate_repo_paths() -> None: + _assert_repo_path(DATA_DIR, "community data directory") + _assert_repo_path(ROSTER_PATH, "community roster") + _assert_repo_path(MAP_PATH, "GitHub mapping") + _assert_repo_path(AVATAR_DIR, "community avatar directory") + + +def _commit_bundle(candidate: dict, candidate_avatars: dict[str, bytes]) -> None: + """Install verified immutable assets, then atomically publish the roster.""" + _validate_repo_paths() + AVATAR_DIR.mkdir(parents=True, exist_ok=True) + referenced = { + pathlib.PurePosixPath(person["avatar"]).name + for role in candidate["roles"].values() + for person in role + if person.get("avatar") + } + existing = {path.name: path for path in AVATAR_DIR.glob("*.webp")} + for name, avatar in sorted(candidate_avatars.items()): + _validate_avatar_blob(name, avatar) + destination = AVATAR_DIR / name + if destination.exists() or destination.is_symlink(): + try: + if destination.is_symlink(): + raise RosterError(f"candidate destination is a symlink: {destination}") + _validate_avatar_blob(name, destination.read_bytes()) + continue + except RosterError: + pass + staged = AVATAR_DIR / f".{name}.candidate" + try: + _copy_candidate(avatar, staged) + os.replace(staged, destination) + finally: + if staged.exists(): + _unlink(staged) + raw = (json.dumps(candidate, indent=2, ensure_ascii=False) + "\n").encode() + # This atomic replace is the commit point. Failures before it leave the + # last-good roster selected; installed content-addressed assets are safe + # unreferenced candidates. + _atomic_write(ROSTER_PATH, raw) + for name in sorted(set(existing) - referenced): + try: + _unlink(AVATAR_DIR / name) + except OSError as exc: + print( + f"::warning file=static/img/community/avatars/{name}::" + f"could not remove unreferenced avatar: {exc}", + file=sys.stderr, + ) + + +def refresh() -> None: + _validate_repo_paths() + DATA_DIR.mkdir(parents=True, exist_ok=True) + source_data = {key: _fetch_json(url) for key, url in SOURCES.items()} + candidate = build_roster(source_data["committee"], source_data["projects"], source_data["people"], _read_json(MAP_PATH)) + work = pathlib.Path(tempfile.mkdtemp(prefix=".community-refresh-", dir=DATA_DIR)) + try: + candidate_avatars = work / "avatars" + _install_avatars(candidate, candidate_avatars) + avatar_bytes = {path.name: path.read_bytes() for path in candidate_avatars.glob("*.webp")} + finally: + # Candidate cleanup is deliberately completed before the checked-in + # bundle changes, so cleanup failure cannot publish a new roster. + shutil.rmtree(work) + _commit_bundle(candidate, avatar_bytes) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("refresh") + validate = commands.add_parser("validate") + validate.add_argument("--warn-after-days", type=int, default=90) + validate.add_argument("--artifact", type=pathlib.Path, help="validate a prebuilt Hugo artifact") + args = parser.parse_args() + try: + if args.command == "refresh": + refresh() + else: + if args.warn_after_days < 0: + raise RosterError("--warn-after-days must be non-negative") + for warning in validate_bundle(args.warn_after_days): + print(f"::warning file=data/community/roster.json::{warning}") + if args.artifact: + validate_rendered_outputs(args.artifact.resolve()) + except (OSError, RosterError, urllib.error.URLError) as exc: + print(f"community roster: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fixtures/community_search_queries.json b/scripts/fixtures/community_search_queries.json new file mode 100644 index 0000000000..a65cfdcb3c --- /dev/null +++ b/scripts/fixtures/community_search_queries.json @@ -0,0 +1,26 @@ +[ + {"locale": "en", "query": "HugeGraph overview", "expected_ref": "/docs/introduction/"}, + {"locale": "en", "query": "server quickstart", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-server/"}, + {"locale": "en", "query": "distributed storage", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-hstore/"}, + {"locale": "en", "query": "placement driver", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-pd/"}, + {"locale": "en", "query": "graph computing", "expected_ref": "/docs/quickstart/computing/hugegraph-computer/"}, + {"locale": "en", "query": "bulk import", "expected_ref": "/docs/quickstart/toolchain/hugegraph-loader/"}, + {"locale": "en", "query": "graph visualization", "expected_ref": "/docs/quickstart/toolchain/hugegraph-hubble/"}, + {"locale": "en", "query": "Java client", "expected_ref": "/docs/clients/"}, + {"locale": "en", "query": "HugeGraph REST API", "expected_ref": "/docs/clients/restful-api/"}, + {"locale": "en", "query": "server config", "expected_ref": "/docs/config/config-guide/"}, + {"locale": "en", "query": "StandardAuthenticator", "expected_ref": "/docs/config/config-authentication/"}, + {"locale": "en", "query": "release artifacts", "expected_ref": "/docs/download/download/"}, + {"locale": "cn", "query": "HugeGraph 介绍", "expected_ref": "/cn/docs/introduction/"}, + {"locale": "cn", "query": "Server 快速开始", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-server/"}, + {"locale": "cn", "query": "分布式存储", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-hstore/"}, + {"locale": "cn", "query": "元数据管理", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-pd/"}, + {"locale": "cn", "query": "图计算", "expected_ref": "/cn/docs/quickstart/computing/hugegraph-computer/"}, + {"locale": "cn", "query": "批量导入", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-loader/"}, + {"locale": "cn", "query": "图可视化", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-hubble/"}, + {"locale": "cn", "query": "Java 客户端", "expected_ref": "/cn/docs/clients/"}, + {"locale": "cn", "query": "HugeGraph REST API", "expected_ref": "/cn/docs/clients/restful-api/"}, + {"locale": "cn", "query": "Server 配置", "expected_ref": "/cn/docs/config/config-guide/"}, + {"locale": "cn", "query": "权限配置", "expected_ref": "/cn/docs/config/config-authentication/"}, + {"locale": "cn", "query": "发布包", "expected_ref": "/cn/docs/download/download/"} +] diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py new file mode 100644 index 0000000000..d916c444b5 --- /dev/null +++ b/scripts/test_community_roster.py @@ -0,0 +1,820 @@ +import hashlib +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / "scripts" / "community_roster.py") +roster = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(roster) + + +def strip_github_mappings(candidate: dict) -> dict: + for person in candidate["roles"]["pmc"] + candidate["roles"]["committers"]: + person.pop("github", None) + person.pop("avatar", None) + person["profile_url"] = f"https://people.apache.org/phonebook.html?uid={person['asf_id']}" + return candidate + + +class FakeResponse: + def __init__(self, raw, *, url, content_type, status=200): + self.raw = raw + self.url = url + self.headers = {"Content-Type": content_type} + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def geturl(self): + return self.url + + def read(self, limit=-1): + return self.raw if limit < 0 else self.raw[:limit] + + +class CommunityRosterTests(unittest.TestCase): + def fixture(self): + return ( + {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair Person"}}, "roster": {"chair": {}, "zeta": {}}}}}, + {"projects": {"hugegraph": {"owners": ["zeta", "chair"], "members": ["other", "zeta", "chair"]}}}, + {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": "Alpha Owner"}, "other": {"name": "Beta Committer"}}}, + {"schema_version": 1, "mappings": {}}, + ) + + def test_build_roster_derives_roles_and_order(self): + candidate = roster.build_roster(*self.fixture()) + self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in candidate["roles"]["pmc"]]) + self.assertEqual(["other"], [p["asf_id"] for p in candidate["roles"]["committers"]]) + self.assertTrue(candidate["roles"]["pmc"][0]["chair"]) + + def test_github_login_is_default_display_name_with_explicit_name_override(self): + committee, projects, people, _ = self.fixture() + mapping = { + "schema_version": 1, + "mappings": {"zeta": {"login": "willem-user", "user_id": 1}}, + } + candidate = roster.build_roster(committee, projects, people, mapping) + zeta = next(person for person in candidate["roles"]["pmc"] if person["asf_id"] == "zeta") + self.assertEqual("willem-user", zeta["name"]) + mapping["public_names"] = {"zeta": "Willem Jiang"} + candidate = roster.build_roster(committee, projects, people, mapping) + zeta = next(person for person in candidate["roles"]["pmc"] if person["asf_id"] == "zeta") + self.assertEqual("Willem Jiang", zeta["name"]) + + def test_display_order_can_override_public_name_sorting(self): + committee, projects, people, _ = self.fixture() + projects["projects"]["hugegraph"]["owners"] = ["zeta", "chair", "alpha"] + projects["projects"]["hugegraph"]["members"] = ["other", "zeta", "chair", "alpha"] + committee["committees"]["hugegraph"]["roster"]["alpha"] = {} + people["people"]["alpha"] = {"name": "Carp84"} + mapping = {"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["zeta", "alpha"]}} + candidate = roster.build_roster(committee, projects, people, mapping) + self.assertEqual(["chair", "zeta", "alpha"], [p["asf_id"] for p in candidate["roles"]["pmc"]]) + + def test_same_names_use_asf_id_tiebreaker_across_hash_seeds(self): + program = f""" +import importlib.util, json +spec = importlib.util.spec_from_file_location("community_roster", {str(ROOT / "scripts/community_roster.py")!r}) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +committee = {{"committees": {{"hugegraph": {{"chair": {{"chair": {{}}}}, "roster": {{"chair": {{}}, "zeta": {{}}, "alpha": {{}}}}}}}}}} +projects = {{"projects": {{"hugegraph": {{"owners": ["zeta", "chair", "alpha"], "members": ["zeta", "chair", "alpha"]}}}}}} +people = {{"people": {{"chair": {{"name": "Chair"}}, "zeta": {{"name": "Same Name"}}, "alpha": {{"name": "Same Name"}}}}}} +result = module.build_roster(committee, projects, people, {{"schema_version": 1, "mappings": {{}}}}) +print(json.dumps([person["asf_id"] for person in result["roles"]["pmc"]])) +""" + outputs = [] + for seed in ("1", "777"): + environment = {**os.environ, "PYTHONHASHSEED": seed} + outputs.append(subprocess.check_output([sys.executable, "-c", program], env=environment, text=True)) + self.assertEqual(outputs[0], outputs[1]) + self.assertEqual(["chair", "alpha", "zeta"], json.loads(outputs[0])) + + def test_build_roster_rejects_committee_ldap_drift(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"].pop("zeta") + with self.assertRaisesRegex(roster.RosterError, "disagree"): + roster.build_roster(committee, projects, people, mapping) + + def test_build_roster_rejects_duplicate_ldap_ids(self): + for field in ("owners", "members"): + with self.subTest(field=field): + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"][field].append( + projects["projects"]["hugegraph"][field][0] + ) + with self.assertRaisesRegex( + roster.RosterError, + rf"LDAP project {field} contains duplicate ASF IDs", + ): + roster.build_roster(committee, projects, people, mapping) + + def test_mapping_requires_unique_numeric_ids(self): + mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", "user_id": 1}, "two": {"login": "other", "user_id": 1}}} + with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub user_id"): + roster._validate_mapping(mapping, {"one", "two"}) + + def test_mapping_rejects_invalid_identity_characters(self): + with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"): + roster._validate_mapping( + {"schema_version": 1, "mappings": {"Bad ID": {"login": "valid", "user_id": 1}}}, + {"Bad ID"}, + ) + with self.assertRaisesRegex(roster.RosterError, "invalid GitHub login"): + roster._validate_mapping( + {"schema_version": 1, "mappings": {"valid": {"login": "bad/login", "user_id": 1}}}, + {"valid"}, + ) + + def test_avatar_metadata_is_stripped(self): + vp8x = b"VP8X" + (10).to_bytes(4, "little") + bytes([0x2D]) + b"\0" * 9 + exif = b"EXIF" + (4).to_bytes(4, "little") + b"meta" + iccp = b"ICCP" + (4).to_bytes(4, "little") + b"icc!" + payload = b"WEBP" + vp8x + exif + iccp + raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload + stripped = roster._strip_webp_metadata(raw) + self.assertNotIn(b"EXIF", stripped) + self.assertNotIn(b"ICCP", stripped) + self.assertEqual(0, stripped[20] & 0x2D) + + def test_truncated_vp8x_without_image_bitstream_is_rejected(self): + vp8x = b"VP8X" + (10).to_bytes(4, "little") + b"\0" * 10 + payload = b"WEBP" + vp8x + raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload + with self.assertRaisesRegex(roster.RosterError, "no decodable"): + roster._validate_webp(raw) + + def test_network_response_contracts_are_bounded_and_allowlisted(self): + with self.assertRaisesRegex(roster.RosterError, "not allowlisted"): + roster._read_bounded_response( + FakeResponse(b"{}", url="https://evil.example/data", content_type="application/json"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + with self.assertRaisesRegex(roster.RosterError, "Content-Type"): + roster._read_bounded_response( + FakeResponse(b"{}", url="https://whimsy.apache.org/data", content_type="text/html"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + with self.assertRaisesRegex(roster.RosterError, "exceeds"): + roster._read_bounded_response( + FakeResponse(b"x" * 11, url="https://whimsy.apache.org/data", content_type="application/json"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + + def test_redirect_is_rejected_before_following_disallowed_host(self): + handler = roster._AllowlistedRedirectHandler({"whimsy.apache.org"}, "JSON source") + with self.assertRaisesRegex(roster.RosterError, "not allowlisted"): + handler.redirect_request( + mock.Mock(), + None, + 302, + "Found", + {}, + "http://127.0.0.1/private", + ) + + def test_malformed_json_and_encoder_timeout_are_roster_errors(self): + response = FakeResponse( + b"{bad", + url="https://whimsy.apache.org/public/committee-info.json", + content_type="application/json", + ) + with mock.patch.object(roster, "_open_allowlisted", return_value=response): + with self.assertRaisesRegex(roster.RosterError, "malformed JSON"): + roster._fetch_json(roster.SOURCES["committee"]) + avatar = FakeResponse( + b"not-an-image", + url="https://avatars.githubusercontent.com/u/1?s=128&v=4", + content_type="image/png", + ) + with mock.patch.object(roster, "_open_allowlisted", return_value=avatar), \ + mock.patch.object(roster.shutil, "which", return_value="/fake/cwebp"), \ + mock.patch.object(roster.subprocess, "run", side_effect=subprocess.TimeoutExpired("cwebp", 20)): + with self.assertRaisesRegex(roster.RosterError, "cwebp failed"): + roster._avatar_bytes(1) + + def test_nested_source_schema_errors_are_roster_errors(self): + committee, projects, people, mapping = self.fixture() + projects["projects"] = [] + with self.assertRaisesRegex(roster.RosterError, "projects and committees objects"): + roster.build_roster(committee, projects, people, mapping) + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"]["owners"] = [[]] + with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"): + roster.build_roster(committee, projects, people, mapping) + with tempfile.TemporaryDirectory(prefix="community-json-root-") as directory: + path = pathlib.Path(directory) / "array.json" + path.write_text("[]") + with self.assertRaisesRegex(roster.RosterError, "JSON root must be an object"): + roster._read_json(path) + + def test_checked_in_bundle_validates(self): + self.assertEqual([], roster.validate_bundle(90)) + + def test_unmapped_profile_must_be_exact_phonebook_url(self): + with tempfile.TemporaryDirectory(prefix="community-profile-test-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(roster.ROSTER_PATH.read_text()) + strip_github_mappings(candidate) + candidate["roles"]["committers"][0]["profile_url"] = "https://example.invalid/profile" + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps({"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}})) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "unmapped profile URL mismatch"): + roster.validate_bundle(90) + + def test_chair_values_must_be_strict_booleans(self): + with tempfile.TemporaryDirectory(prefix="community-chair-test-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(roster.ROSTER_PATH.read_text()) + candidate["roles"]["committers"][0]["chair"] = 0 + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(roster.MAP_PATH.read_text()) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "chair must be boolean"): + roster.validate_bundle(90) + + def test_avatar_path_rejects_extra_segments_and_symlinks(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + strip_github_mappings(base) + asf_id = base["roles"]["committers"][0]["asf_id"] + mapping = {"schema_version": 1, "mappings": {asf_id: {"login": "valid-user", "user_id": 1}}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}} + for avatar in ( + "/img/community/avatars/extra/" + "a" * 64 + ".webp", + "/img/community/avatars/../" + "a" * 64 + ".webp", + ): + with self.subTest(avatar=avatar), tempfile.TemporaryDirectory(prefix="community-avatar-path-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id) + member.update(github=mapping["mappings"][asf_id], avatar=avatar, profile_url="https://github.com/valid-user") + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "needs a local avatar"): + roster.validate_bundle(90) + with tempfile.TemporaryDirectory(prefix="community-avatar-link-") as directory: + root = pathlib.Path(directory) + avatar_dir = root / "avatars" + avatar_dir.mkdir() + raw = b"target" + digest = hashlib.sha256(raw).hexdigest() + target = root / "target.webp" + target.write_bytes(raw) + (avatar_dir / f"{digest}.webp").symlink_to(target) + candidate = json.loads(json.dumps(base)) + member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id) + member.update( + github=mapping["mappings"][asf_id], + avatar=f"/img/community/avatars/{digest}.webp", + profile_url="https://github.com/valid-user", + ) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir): + with self.assertRaisesRegex(roster.RosterError, "must not be a symlink"): + roster.validate_bundle(90) + + def test_avatar_directory_parent_symlink_is_rejected(self): + with tempfile.TemporaryDirectory(prefix="community-avatar-parent-") as directory: + root = pathlib.Path(directory) + outside = root / "outside" + outside.mkdir() + avatar_link = root / "static" / "img" / "community" / "avatars" + avatar_link.parent.mkdir(parents=True) + avatar_link.symlink_to(outside, target_is_directory=True) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root / "data" / "community"), \ + mock.patch.object(roster, "ROSTER_PATH", root / "data" / "community" / "roster.json"), \ + mock.patch.object(roster, "MAP_PATH", root / "data" / "community" / "github-map.json"), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_link): + with self.assertRaisesRegex(roster.RosterError, "symlink path components"): + roster._validate_repo_paths() + + def test_member_name_and_initials_must_be_non_empty_and_derived(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + mapping = {"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}} + for field, value, message in ( + ("name", "", "name must be non-empty"), + ("initials", "", "initials mismatch"), + ): + with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-identity-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + candidate["roles"]["committers"][0][field] = value + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, message): + roster.validate_bundle(90) + + def test_local_roster_schema_errors_are_roster_errors(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + mapping = {"schema_version": 1, "mappings": {}, "display_order": {"pmc": ["jin", "zhaocong", "lidongdai", "liyu"]}} + mutations = ( + ("asf_id", [], "invalid ASF ID"), + ("name", 123, "name must be non-empty"), + ("retrieved_at", None, "ISO-8601 UTC string"), + ) + for field, value, message in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-schema-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + strip_github_mappings(candidate) + if field == "retrieved_at": + candidate[field] = value + else: + candidate["roles"]["committers"][0][field] = value + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, message): + roster.validate_bundle(90) + + def test_refresh_validates_paths_before_creating_data_directory(self): + with tempfile.TemporaryDirectory(prefix="community-refresh-path-") as directory: + root = pathlib.Path(directory) + outside = root / "outside" + outside.mkdir() + (root / "data").symlink_to(outside, target_is_directory=True) + data_dir = root / "data" / "community" + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", data_dir), \ + mock.patch.object(roster, "ROSTER_PATH", data_dir / "roster.json"), \ + mock.patch.object(roster, "MAP_PATH", data_dir / "github-map.json"), \ + mock.patch.object(roster, "AVATAR_DIR", root / "static" / "img" / "community" / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "symlink path components"): + roster.refresh() + self.assertFalse((outside / "community").exists()) + + def test_validator_rejects_same_name_out_of_asf_id_order(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"]["alpha"] = {} + projects["projects"]["hugegraph"]["owners"].append("alpha") + projects["projects"]["hugegraph"]["members"].append("alpha") + people["people"]["zeta"]["name"] = "Same Name" + people["people"]["alpha"] = {"name": "Same Name"} + candidate = roster.build_roster(committee, projects, people, mapping) + candidate["roles"]["pmc"][1:] = reversed(candidate["roles"]["pmc"][1:]) + with tempfile.TemporaryDirectory(prefix="community-order-test-") as directory: + root = pathlib.Path(directory) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "sorted by public name"): + roster.validate_bundle(90) + + def test_fetch_failure_preserves_last_good(self): + original, old_fetch = roster.ROSTER_PATH.read_bytes(), roster._fetch_json + try: + roster._fetch_json = lambda _url: (_ for _ in ()).throw(OSError("network down")) + with self.assertRaises(OSError): + roster.refresh() + finally: + roster._fetch_json = old_fetch + self.assertEqual(original, roster.ROSTER_PATH.read_bytes()) + + def test_refresh_duplicate_ldap_ids_preserves_last_good(self): + for field in ("owners", "members"): + with self.subTest(field=field), tempfile.TemporaryDirectory( + prefix="community-duplicate-test-" + ) as directory: + root = pathlib.Path(directory) + data_dir = root / "data" + roster_path, map_path = data_dir / "roster.json", data_dir / "github-map.json" + data_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"][field].append( + projects["projects"]["hugegraph"][field][0] + ) + sources = { + roster.SOURCES["committee"]: committee, + roster.SOURCES["projects"]: projects, + roster.SOURCES["people"]: people, + } + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", data_dir), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"), \ + mock.patch.object(roster, "_fetch_json", side_effect=sources.__getitem__), \ + mock.patch.object(roster, "_commit_bundle") as commit: + with self.assertRaisesRegex( + roster.RosterError, + rf"LDAP project {field} contains duplicate ASF IDs", + ): + roster.refresh() + commit.assert_not_called() + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_copy_failure_preserves_last_good_bundle(self): + with tempfile.TemporaryDirectory(prefix="community-copy-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + (avatar_dir / "old.webp").write_bytes(b"old") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_copy_candidate", side_effect=OSError("copy failed")): + with self.assertRaisesRegex(OSError, "copy failed"): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes()) + + def test_candidate_cleanup_failure_does_not_publish_roster(self): + with tempfile.TemporaryDirectory(prefix="community-cleanup-test-") as directory: + root = pathlib.Path(directory) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_bytes(b"last-good\n") + map_path.write_text('{"schema_version": 1, "mappings": {}}') + candidate = {"roles": {"pmc": [], "committers": []}} + with mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_fetch_json", return_value={}), \ + mock.patch.object(roster, "build_roster", return_value=candidate), \ + mock.patch.object(roster, "_install_avatars"), \ + mock.patch.object(roster.shutil, "rmtree", side_effect=OSError("cleanup failed")): + with self.assertRaisesRegex(OSError, "cleanup failed"): + roster.refresh() + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_atomic_roster_write_failure_keeps_last_good_selected(self): + with tempfile.TemporaryDirectory(prefix="community-write-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_atomic_write", side_effect=OSError("write failed")): + with self.assertRaisesRegex(OSError, "write failed"): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_orphan_unlink_failure_is_a_successful_commit_warning(self): + with tempfile.TemporaryDirectory(prefix="community-unlink-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + (avatar_dir / "old.webp").write_bytes(b"old") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + real_unlink, failed = roster._unlink, False + + def fail_once(path): + nonlocal failed + if path.name == "old.webp" and not failed: + failed = True + raise OSError("unlink failed") + real_unlink(path) + + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_unlink", side_effect=fail_once): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertNotEqual(b"last-good\n", roster_path.read_bytes()) + self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes()) + self.assertEqual(b"new", (avatar_dir / "new.webp").read_bytes()) + + def test_corrupt_existing_candidate_destination_is_replaced(self): + with tempfile.TemporaryDirectory(prefix="community-replace-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + raw = b"new" + name = f"{__import__('hashlib').sha256(raw).hexdigest()}.webp" + destination = avatar_dir / name + destination.write_bytes(b"corrupt") + candidate = {"roles": {"pmc": [{"avatar": f"/img/community/avatars/{name}"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_webp"): + roster._commit_bundle(candidate, {name: raw}) + self.assertEqual(raw, destination.read_bytes()) + + +class CommunityContentContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._site = tempfile.TemporaryDirectory(prefix="community-content-site-") + hugo_version = subprocess.check_output(["hugo", "version"], text=True) + if not hugo_version.startswith("hugo v0.165.0") or "+extended" not in hugo_version: + raise RuntimeError( + f"Community render contracts require Hugo v0.165.0 Extended: {hugo_version.strip()}" + ) + environment = {**os.environ, "GOPROXY": "off"} + subprocess.run( + ["hugo", "--quiet", "--destination", cls._site.name], + cwd=ROOT, + env=environment, + text=True, + capture_output=True, + check=True, + ) + cls.site = pathlib.Path(cls._site.name) + + @classmethod + def tearDownClass(cls): + cls._site.cleanup() + + def test_search_metadata_covers_fixed_bilingual_entries(self): + entries = [ + "docs/introduction/_index.md", + "docs/quickstart/hugegraph/hugegraph-server.md", + "docs/quickstart/hugegraph/hugegraph-hstore.md", + "docs/quickstart/hugegraph/hugegraph-pd.md", + "docs/quickstart/computing/hugegraph-computer.md", + "docs/quickstart/toolchain/hugegraph-loader.md", + "docs/quickstart/toolchain/hugegraph-hubble.md", + "docs/clients/_index.md", + "docs/clients/restful-api/_index.md", + "docs/config/config-guide.md", + "docs/config/config-authentication.md", + "docs/download/download.md", + ] + for language in ("en", "cn"): + for relative in entries: + text = (ROOT / "content" / language / relative).read_text(encoding="utf-8") + self.assertIn("search_keywords:", text, f"{language}/{relative}") + self.assertIn("search_boost:", text, f"{language}/{relative}") + + def test_docs_roots_respect_core_platform_llmsfull_ownership(self): + versions = { + entry["id"] + for entry in json.loads((ROOT / "versions.json").read_text(encoding="utf-8"))["versions"] + } + core_platform_integrated = {"1.3", "1.0"} <= versions + for language in ("en", "cn"): + text = (ROOT / "content" / language / "docs/_index.md").read_text(encoding="utf-8") + frontmatter = text.split("---", 2)[1] + if core_platform_integrated: + self.assertIn("outputs: [HTML, RSS, print, markdown, LLMSFULL]", frontmatter) + else: + self.assertNotIn("LLMSFULL", frontmatter) + + def test_component_pilots_are_bilingual_and_scoped(self): + for language in ("en", "cn"): + server = (ROOT / "content" / language / "docs/quickstart/hugegraph/hugegraph-server.md").read_text() + config = (ROOT / "content" / language / "docs/config/config-guide.md").read_text() + vertex = (ROOT / "content" / language / "docs/clients/restful-api/vertex.md").read_text() + self.assertIn("{.steps}", server) + self.assertIn('filename="conf/gremlin-server.yaml"', config) + self.assertIn(".full-width", vertex) + self.assertIn("{#vertex-id-strategy", vertex) + + def test_component_pilots_render_in_html_print_and_markdown(self): + for prefix in ("", "cn/"): + outputs = { + "server_html": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.html", + "server_print": self.site / prefix / "_print/docs/quickstart/hugegraph/index.html", + "server_md": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.md", + "config_html": self.site / prefix / "docs/config/config-guide/index.html", + "config_print": self.site / prefix / "_print/docs/config/index.html", + "config_md": self.site / prefix / "docs/config/config-guide/index.md", + "vertex_html": self.site / prefix / "docs/clients/restful-api/vertex/index.html", + "vertex_print": self.site / prefix / "_print/docs/clients/restful-api/index.html", + "vertex_md": self.site / prefix / "docs/clients/restful-api/vertex/index.md", + } + rendered = {key: path.read_text(encoding="utf-8") for key, path in outputs.items()} + self.assertIn('class="steps"', rendered["server_html"]) + self.assertIn('class="steps"', rendered["server_print"]) + self.assertIn("{.steps}", rendered["server_md"]) + for key in ("config_html", "config_print", "config_md"): + self.assertIn("conf/gremlin-server.yaml", rendered[key]) + self.assertIn('id="vertex-id-strategy"', rendered["vertex_html"]) + self.assertIn('id="vertex-id-strategy"', rendered["vertex_print"]) + self.assertIn("{#vertex-id-strategy .full-width", rendered["vertex_md"]) + + def test_community_markdown_follows_section_order_and_about_is_unchanged(self): + expected = { + "community/index.md": ( + "## Join the Apache HugeGraph community", + "## Project members", + "## Get involved", + "## Learn how the project works", + ), + "cn/community/index.md": ( + "## 加入 Apache HugeGraph 社区", + "## 项目成员", + "## 参与社区", + "## 了解项目运作方式", + ), + } + for relative, markers in expected.items(): + rendered = (self.site / relative).read_text(encoding="utf-8") + expected_title = "# 社区" if relative.startswith("cn/") else "# Community" + self.assertTrue(rendered.startswith(expected_title + "\n")) + self.assertNotIn("td-page-meta__footer", rendered) + positions = [rendered.index(marker) for marker in markers] + self.assertEqual(positions, sorted(positions)) + member_heading = "项目成员" if relative.startswith("cn/") else "Project members" + self.assertRegex(rendered, rf"(?m)^## {member_heading}$") + about = { + "about/index.md": ( + "## One ecosystem for graph data and graph intelligence", + "HugeGraph is an Apache top-level project", + ), + "cn/about/index.md": ( + "## 连接图数据与图智能的一体化生态", + "HugeGraph 是 Apache 顶级项目", + ), + } + for relative, markers in about.items(): + rendered = (self.site / relative).read_text(encoding="utf-8") + self.assertNotIn("Project members", rendered) + for marker in markers: + self.assertIn(marker, rendered) + + def test_explicit_artifact_validator_accepts_prebuilt_site(self): + roster.validate_rendered_outputs(self.site) + result = subprocess.run( + [ + sys.executable, + "scripts/community_roster.py", + "validate", + "--warn-after-days", + "90", + "--artifact", + str(self.site), + ], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_artifact_validator_rejects_swapped_role_sections(self): + outputs = ( + "community/index.html", + "_print/community/index.html", + "community/index.md", + "cn/community/index.html", + "cn/_print/community/index.html", + "cn/community/index.md", + ) + for swapped_relative in ( + "community/index.html", + "_print/community/index.html", + "cn/community/index.html", + "cn/_print/community/index.html", + ): + with self.subTest(output=swapped_relative), tempfile.TemporaryDirectory( + prefix="community-role-output-" + ) as directory: + destination = pathlib.Path(directory) + for relative in outputs: + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.site / relative, target) + path = destination / swapped_relative + rendered = path.read_text(encoding="utf-8") + match = re.search( + r'(]*data-community-role=(?:"pmc"|pmc)[^>]*>.*?)' + r"(\s*)" + r'(]*data-community-role=(?:"committers"|committers)[^>]*>.*?)', + rendered, + flags=re.DOTALL, + ) + self.assertIsNotNone(match) + rendered = ( + rendered[: match.start()] + + match.group(3) + + match.group(2) + + match.group(1) + + rendered[match.end() :] + ) + path.write_text(rendered, encoding="utf-8") + with self.assertRaisesRegex(roster.RosterError, "role section order drift"): + roster.validate_rendered_outputs(destination) + + def test_artifact_validator_rejects_plain_text_profile_urls(self): + with tempfile.TemporaryDirectory(prefix="community-fake-output-") as directory: + destination = pathlib.Path(directory) + roles = json.loads(roster.ROSTER_PATH.read_text())["roles"] + for relative in ( + "community/index.html", + "_print/community/index.html", + "cn/community/index.html", + "cn/_print/community/index.html", + ): + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '
' + + " ".join(person["profile_url"] for person in roles["pmc"]) + + '
' + + " ".join(person["profile_url"] for person in roles["committers"]) + + "
" + ) + for relative, title in ( + ("community/index.md", "Project members"), + ("cn/community/index.md", "项目成员"), + ): + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"## {title}\n\n### PMC\n" + + "\n".join(person["profile_url"] for person in roles["pmc"]) + + "\n\n### Committers\n" + + "\n".join(person["profile_url"] for person in roles["committers"]) + ) + with self.assertRaisesRegex(roster.RosterError, "link parity drift"): + roster.validate_rendered_outputs(destination) + + def test_fixed_metadata_is_present_in_actual_offline_indexes(self): + fixture = json.loads( + (ROOT / "scripts/fixtures/community_search_queries.json").read_text(encoding="utf-8") + ) + self.assertEqual(24, len(fixture)) + self.assertEqual(24, len({(item["locale"], item["query"]) for item in fixture})) + for language in ("en", "cn"): + indexes = list(self.site.glob(f"offline-search-index.{language}.*.json")) + self.assertEqual(1, len(indexes)) + records = {record["ref"]: record for record in json.loads(indexes[0].read_text())} + for item in (entry for entry in fixture if entry["locale"] == language): + ref = item["expected_ref"] + self.assertIn(ref, records) + self.assertTrue(records[ref]["keywords"], ref) + self.assertGreater(records[ref]["boost"], 1, ref) + normalized_query = item["query"].casefold() + searchable = " ".join( + [records[ref]["title"], *records[ref]["keywords"]] + ).casefold() + self.assertIn(normalized_query, searchable, ref) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_download_data.py b/scripts/test_download_data.py new file mode 100644 index 0000000000..5ac7123c46 --- /dev/null +++ b/scripts/test_download_data.py @@ -0,0 +1,217 @@ +import json +import os +import pathlib +import re +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DATA = ROOT / "data" / "downloads" / "asf.json" +PARTIAL = ROOT / "layouts" / "_partials" / "asf-downloads.html" +PAGES = ( + ROOT / "content" / "en" / "docs" / "download" / "download.md", + ROOT / "content" / "cn" / "docs" / "download" / "download.md", +) +I18N = (ROOT / "i18n" / "en.yaml", ROOT / "i18n" / "zh-CN.yaml") +PUBLIC_DIR = pathlib.Path(os.environ["DOWNLOAD_PUBLIC_DIR"]) if os.environ.get("DOWNLOAD_PUBLIC_DIR") else None + +# Exact dist state, verified against +# https://downloads.apache.org/hugegraph// on 2026-09-13. The data +# file must derive exactly these artifacts; a new release edits both the data +# file and this expectation with a fresh dist listing. +EXPECTED_ARTIFACTS = { + "1.7.0": { + "apache-hugegraph-incubating-1.7.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.7.0.tar.gz", + "apache-hugegraph-incubating-1.7.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.7.0-src.tar.gz", + "apache-hugegraph-ai-incubating-1.7.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.7.0-src.tar.gz", + }, + "1.5.0": { + "apache-hugegraph-incubating-1.5.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.5.0.tar.gz", + "apache-hugegraph-incubating-1.5.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.5.0-src.tar.gz", + "apache-hugegraph-ai-incubating-1.5.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.5.0-src.tar.gz", + }, + "1.3.0": { + "apache-hugegraph-incubating-1.3.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.3.0.tar.gz", + "apache-hugegraph-incubating-1.3.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.3.0-src.tar.gz", + "apache-hugegraph-ai-incubating-1.3.0-src.tar.gz", + "apache-hugegraph-commons-incubating-1.3.0-src.tar.gz", + }, + "1.2.0": { + "apache-hugegraph-incubating-1.2.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.2.0.tar.gz", + "apache-hugegraph-incubating-1.2.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.2.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.2.0-src.tar.gz", + "apache-hugegraph-commons-incubating-1.2.0-src.tar.gz", + }, + "1.0.0": { + "apache-hugegraph-incubating-1.0.0.tar.gz", + "apache-hugegraph-toolchain-incubating-1.0.0.tar.gz", + "apache-hugegraph-computer-incubating-1.0.0.tar.gz", + "apache-hugegraph-incubating-1.0.0-src.tar.gz", + "apache-hugegraph-toolchain-incubating-1.0.0-src.tar.gz", + "apache-hugegraph-computer-incubating-1.0.0-src.tar.gz", + "apache-hugegraph-commons-incubating-1.0.0-src.tar.gz", + }, +} + +FIXED_I18N_KEYS = ( + "ui_assets_download", + "download_release_version", + "download_release_date", + "download_release_notes", + "download_table_component", + "download_table_type", + "download_table_mirror", + "download_type_binary", + "download_type_source", + "download_asf_note", +) + + +def load_data() -> dict: + with DATA.open(encoding="utf-8") as handle: + return json.load(handle) + + +def derive_files(release: dict, components: dict) -> set[str]: + files = set() + infix = "-incubating" if release["incubating"] else "" + for kind, suffix in (("binary", ""), ("source", "-src")): + for component_id in release.get(kind, []): + prefix = components[component_id]["prefix"] + files.add(f"{prefix}{infix}-{release['version']}{suffix}.tar.gz") + return files + + +def i18n_keys(path: pathlib.Path) -> set[str]: + keys = set() + for line in path.read_text(encoding="utf-8").splitlines(): + if line and not line.startswith(("#", " ")) and ":" in line: + keys.add(line.split(":", 1)[0].strip()) + return keys + + +class DownloadDataTest(unittest.TestCase): + def setUp(self) -> None: + self.data = load_data() + + def test_schema_and_release_ordering(self) -> None: + data = self.data + self.assertEqual( + set(data), {"$comment", "dist_path", "components", "releases"} + ) + self.assertEqual(data["dist_path"], "hugegraph") + label_keys = [c["label_key"] for c in data["components"].values()] + self.assertEqual(len(label_keys), len(set(label_keys))) + for component_id, component in data["components"].items(): + self.assertRegex(component_id, r"^[a-z][a-z0-9]*\Z") + self.assertRegex(component["prefix"], r"^apache-hugegraph(-[a-z]+)?\Z") + self.assertRegex(component["label_key"], r"^download_component_[a-z]+\Z") + releases = data["releases"] + versions = [release["version"] for release in releases] + self.assertEqual(versions, sorted(versions, key=lambda v: tuple(map(int, v.split("."))), reverse=True)) + self.assertEqual(len(versions), len(set(versions))) + self.assertEqual(sum(1 for release in releases if release.get("latest")), 1) + self.assertTrue(releases[0].get("latest"), "the newest release must be the latest") + for release in releases: + self.assertRegex(release["version"], r"^\d+\.\d+\.\d+\Z") + self.assertRegex(release["date"], r"^\d{4}-\d{2}-\d{2}\Z") + self.assertIsInstance(release["incubating"], bool) + for kind in ("binary", "source"): + ids = release.get(kind, []) + self.assertTrue(ids, f"{release['version']} has no {kind} artifacts") + self.assertEqual(len(ids), len(set(ids))) + for component_id in ids: + self.assertIn(component_id, data["components"]) + + def test_derived_artifacts_match_the_verified_dist_listing(self) -> None: + data = self.data + derived = { + release["version"]: derive_files(release, data["components"]) + for release in data["releases"] + } + self.assertEqual(derived, EXPECTED_ARTIFACTS) + total = sum(len(files) for files in derived.values()) + self.assertEqual(total, 31) + + def test_derived_urls_are_well_formed(self) -> None: + data = self.data + pattern = re.compile(r"^https://[a-z.]+/[A-Za-z0-9./?=_-]+$") + for release in data["releases"]: + for file in derive_files(release, data["components"]): + urls = ( + f"https://www.apache.org/dyn/closer.lua/{data['dist_path']}/" + f"{release['version']}/{file}?action=download", + f"https://downloads.apache.org/{data['dist_path']}/" + f"{release['version']}/{file}.asc", + f"https://downloads.apache.org/{data['dist_path']}/" + f"{release['version']}/{file}.sha512", + ) + for url in urls: + self.assertRegex(url, pattern) + + def test_pages_render_from_data_not_hardcoded_tables(self) -> None: + for page in PAGES: + text = page.read_text(encoding="utf-8") + self.assertIn("{{< asf-downloads latest >}}", text, page) + self.assertIn("{{< asf-downloads archived >}}", text, page) + self.assertNotIn("closer.lua", text, page) + self.assertNotIn(".tar.gz", text, page) + artifact_links = re.findall( + r"downloads\.apache\.org/hugegraph/\d", text + ) + self.assertEqual(artifact_links, [], page) + + def test_partial_derives_urls_with_the_same_tokens(self) -> None: + # The partial re-derives the same filenames and URLs in Go templates; + # lock its literal format tokens to this module's derivation so the + # two cannot drift apart silently. + template = PARTIAL.read_text(encoding="utf-8") + for token in ( + '"-incubating" ""', + '"-src" ""', + '"%s%s-%s%s.tar.gz" $component.prefix $infix $version $suffix', + 'https://www.apache.org/dyn/closer.lua/%s/%s/%s?action=download', + 'https://downloads.apache.org/%s/%s/%s.asc', + 'https://downloads.apache.org/%s/%s/%s.sha512', + ): + self.assertIn(token, template) + + def test_i18n_catalogues_carry_every_label(self) -> None: + data = self.data + required = set(FIXED_I18N_KEYS) + for component in data["components"].values(): + required.add(component["label_key"]) + for catalogue in I18N: + missing = required - i18n_keys(catalogue) + self.assertEqual(missing, set(), catalogue) + + def test_rendered_download_pages_have_verified_rows(self) -> None: + if PUBLIC_DIR is None: + self.skipTest("set DOWNLOAD_PUBLIC_DIR after an aggregate build") + for relative in ("docs/download/download/index.html", "cn/docs/download/download/index.html"): + page = PUBLIC_DIR / relative + self.assertTrue(page.is_file(), page) + text = page.read_text(encoding="utf-8") + self.assertIn("hg-asf-release", text, page) + self.assertIn("1.7.0", text, page) + for version, expected_files in EXPECTED_ARTIFACTS.items(): + self.assertIn(f"hugegraph-{version}-release-notes", text, page) + for filename in expected_files: + self.assertIn(filename, text, page) + self.assertIn(f"/dyn/closer.lua/hugegraph/{version}/{filename}?action=download", text, page) + self.assertIn(f"downloads.apache.org/hugegraph/{version}/{filename}.asc", text, page) + self.assertIn(f"downloads.apache.org/hugegraph/{version}/{filename}.sha512", text, page) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/versioning.py b/scripts/versioning.py index 1b30f3beb2..84d9a2d5fc 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -123,7 +123,7 @@ DOCS_NAV_GROUP_IDS = ("start", "components", "develop", "operate", "reference") DOCS_NAV_GROUP_TITLES = { "en": ("Get Started", "Components", "Develop", "Operate", "Reference"), - "cn": ("开始", "组件", "开发", "运维", "参考"), + "cn": ("开始", "组件", "开发", "配置", "参考"), } DOCS_NAV_EXPECTED_STATS = { "latest": { diff --git a/static/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp b/static/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp new file mode 100644 index 0000000000..99608aba00 Binary files /dev/null and b/static/img/community/avatars/045e2234792e0baba143f9c0bfd0064d012d3de1d57dec883c3e3fa6901d3f85.webp differ diff --git a/static/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp b/static/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp new file mode 100644 index 0000000000..b386f5f027 Binary files /dev/null and b/static/img/community/avatars/08f13d01a0fc2048ef26199ee51aee806047a84fecaed5829893494dbd35736b.webp differ diff --git a/static/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp b/static/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp new file mode 100644 index 0000000000..e7e43482f3 Binary files /dev/null and b/static/img/community/avatars/266c764e5bf245a1e02fbb808268a96e04b8acb73f1e9dc8a4c1cd9053ce65b0.webp differ diff --git a/static/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp b/static/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp new file mode 100644 index 0000000000..75a3bcacd6 Binary files /dev/null and b/static/img/community/avatars/2b8f6cf3f85038d5a0668a64df2ef2131eaf935f9e8abf69e468cbeb1474ac75.webp differ diff --git a/static/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp b/static/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp new file mode 100644 index 0000000000..895eb85318 Binary files /dev/null and b/static/img/community/avatars/3953b178d91c3cfec7f994316117bfb1d1bbee78ea050d920047f3e9874f81f1.webp differ diff --git a/static/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp b/static/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp new file mode 100644 index 0000000000..f28c464556 Binary files /dev/null and b/static/img/community/avatars/55d7d9385e8d21e38d7cfcdadfc559f0002ef1b63a2a517fda0cd5a09f4a820d.webp differ diff --git a/static/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp b/static/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp new file mode 100644 index 0000000000..09a262871a Binary files /dev/null and b/static/img/community/avatars/62e0171bfb271e4938963a26876ae28f5f44d874454d197fd5f7a08595f8d7a3.webp differ diff --git a/static/img/community/avatars/657d7c6a76e43a96d9da26ab4eab361172ebb3dae217a71f79b7531a7637abdc.webp b/static/img/community/avatars/657d7c6a76e43a96d9da26ab4eab361172ebb3dae217a71f79b7531a7637abdc.webp new file mode 100644 index 0000000000..e50000a591 Binary files /dev/null and b/static/img/community/avatars/657d7c6a76e43a96d9da26ab4eab361172ebb3dae217a71f79b7531a7637abdc.webp differ diff --git a/static/img/community/avatars/695bd7904d7f4cfab98b7d812c769f481b73cf9d8f1d6d985e865d87e633e36a.webp b/static/img/community/avatars/695bd7904d7f4cfab98b7d812c769f481b73cf9d8f1d6d985e865d87e633e36a.webp new file mode 100644 index 0000000000..f282c34314 Binary files /dev/null and b/static/img/community/avatars/695bd7904d7f4cfab98b7d812c769f481b73cf9d8f1d6d985e865d87e633e36a.webp differ diff --git a/static/img/community/avatars/6962957beb238414dd798740aada61a514377c349d8fed86f091792b303e76e0.webp b/static/img/community/avatars/6962957beb238414dd798740aada61a514377c349d8fed86f091792b303e76e0.webp new file mode 100644 index 0000000000..31f6159abb Binary files /dev/null and b/static/img/community/avatars/6962957beb238414dd798740aada61a514377c349d8fed86f091792b303e76e0.webp differ diff --git a/static/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp b/static/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp new file mode 100644 index 0000000000..a25001e789 Binary files /dev/null and b/static/img/community/avatars/7590d40579ad10dacf0a2f9de0439a8cedd7c8ab613b49be5e75991f98c78dac.webp differ diff --git a/static/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp b/static/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp new file mode 100644 index 0000000000..2169234a1f Binary files /dev/null and b/static/img/community/avatars/87ba3c0066e31c0f584706ea1b7782a18a74ed0957ca02c1ac16e1bfa58c899f.webp differ diff --git a/static/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp b/static/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp new file mode 100644 index 0000000000..875ebfdc59 Binary files /dev/null and b/static/img/community/avatars/ab585341e46cfbf10b3fe426030f37ae54cdc12eac678debcd8989b12f64ee59.webp differ diff --git a/static/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp b/static/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp new file mode 100644 index 0000000000..196d52cdc8 Binary files /dev/null and b/static/img/community/avatars/af583d1e4ffed9bc08ef7ec2e555b619058c665b2cc67eadac43df2e454b68e6.webp differ diff --git a/static/img/community/avatars/c936571abaf109fd7049f976c1767bc21d05f4a7ed018bc232cdc0c83cbf2346.webp b/static/img/community/avatars/c936571abaf109fd7049f976c1767bc21d05f4a7ed018bc232cdc0c83cbf2346.webp new file mode 100644 index 0000000000..eb072d25cc Binary files /dev/null and b/static/img/community/avatars/c936571abaf109fd7049f976c1767bc21d05f4a7ed018bc232cdc0c83cbf2346.webp differ diff --git a/static/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp b/static/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp new file mode 100644 index 0000000000..39c2097cbc Binary files /dev/null and b/static/img/community/avatars/ce12f31198b55a4acaf56732f5d00c5701e4384bb4414126ccfccb9dadacfe1a.webp differ diff --git a/static/img/community/avatars/d3ecd2b51f116bece9a9c4cd6b6df651b1459206d2973b8bbb5bd302015d1a14.webp b/static/img/community/avatars/d3ecd2b51f116bece9a9c4cd6b6df651b1459206d2973b8bbb5bd302015d1a14.webp new file mode 100644 index 0000000000..3262cd5556 Binary files /dev/null and b/static/img/community/avatars/d3ecd2b51f116bece9a9c4cd6b6df651b1459206d2973b8bbb5bd302015d1a14.webp differ diff --git a/static/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp b/static/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp new file mode 100644 index 0000000000..2f1136c6b6 Binary files /dev/null and b/static/img/community/avatars/d959beeff7a37e2528ca79ee2aa2baadb2b03b284dfb3b6159e792fb9a44e31d.webp differ diff --git a/static/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp b/static/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp new file mode 100644 index 0000000000..3359779402 Binary files /dev/null and b/static/img/community/avatars/df8a3cbc33555abcdef083793429fe405a8846fb2994d700d079b9215786aa17.webp differ diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 88bd2ed0d5..396d47dc89 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -1,10 +1,22 @@ const { test, expect } = require("./artifact-test"); const AxeBuilder = require("@axe-core/playwright").default; -for (const route of ["/docs/", "/cn/docs/", "/community/", "/cn/community/"]) { +for (const route of [ + "/docs/", + "/cn/docs/", + "/community/", + "/cn/community/", + "/docs/download/download/", + "/cn/docs/download/download/", +]) { test(`axe WCAG 2.2 AA guard ${route}`, async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto(route); + const response = await page.goto(route); + expect(response && response.ok()).toBeTruthy(); + if (route.includes("/docs/download/")) { + await expect(page.locator(".hg-asf-release").first()).toBeVisible(); + await expect(page.locator(".hg-asf-release").first()).toContainText("1.7.0"); + } await page.addStyleTag({ content: "*,*::before,*::after{animation:none!important;transition:none!important}" }); @@ -22,3 +34,17 @@ for (const route of ["/docs/", "/cn/docs/", "/community/", "/cn/community/"]) { expect(blocking).toEqual([]); }); } + +for (const locale of ["en", "cn"]) { + test(`download table scrolls with the keyboard on mobile ${locale}`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${locale === "cn" ? "/cn" : ""}/docs/download/download/`); + const region = page.locator(".hg-asf-release .td-asset-list__table-wrap").first(); + await region.scrollIntoViewIfNeeded(); + await region.focus(); + await expect(region).toBeFocused(); + await region.press("ArrowRight"); + await expect.poll(() => region.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + }); +} diff --git a/tests/e2e/ai.spec.js b/tests/e2e/ai.spec.js index 56209630f5..ac25ac29d1 100644 --- a/tests/e2e/ai.spec.js +++ b/tests/e2e/ai.spec.js @@ -48,6 +48,9 @@ for (const [locale, route, source, language] of [ await expect(tail).toHaveCount(0); await input.fill("server auth"); await tail.locator("[data-hg-ask-ai]").click(); + await expect(page.locator("[data-hg-ai-consent]")).toBeVisible(); + expect(requests).toEqual([]); + await page.locator("[data-hg-ai-consent] [data-hg-ai-continue]").click(); await expect.poll(() => requests.length).toBe(1); await expect.poll(() => page.evaluate(() => window.__kapaCalls || [])).toContainEqual([ "setSourceGroupIDs", [source] @@ -68,6 +71,31 @@ for (const [locale, route, source, language] of [ }); } +test("AI consent cancel and Escape keep native search local", async ({ page }) => { + const requests = []; + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { + requests.push(route.request().url()); + await route.fulfill({ status: 200, contentType: "text/javascript", body: mockBundle }); + }); + await page.goto(AI_ORIGIN + "/docs/"); + const launcher = page.locator(".hg-ask-ai-launcher"); + await launcher.click(); + const consent = page.locator("[data-hg-ai-consent]"); + await expect(consent).toBeVisible(); + await consent.locator("[data-hg-ai-cancel]").click(); + await expect(consent).toBeHidden(); + await expect(launcher).toBeFocused(); + expect(requests).toEqual([]); + await launcher.click(); + await expect(consent).toBeVisible(); + await consent.press("Escape"); + await expect(consent).toBeHidden(); + await expect(launcher).toBeFocused(); + expect(requests).toEqual([]); + await page.locator("[data-td-shell-search-open]").first().click(); + await expect(page.locator(".td-shell-search__input")).toBeVisible(); +}); + test("AI 500 remains non-blocking and retry issues one fresh request", async ({ page }) => { let attempts = 0; await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { @@ -78,6 +106,9 @@ test("AI 500 remains non-blocking and retry issues one fresh request", async ({ await page.goto(AI_ORIGIN + "/docs/"); const launcher = page.locator(".hg-ask-ai-launcher"); await launcher.dblclick(); + await expect(page.locator("[data-hg-ai-consent]")).toBeVisible(); + expect(attempts).toBe(0); + await page.locator("[data-hg-ai-consent] [data-hg-ai-continue]").click(); await expect.poll(() => attempts).toBe(1); await expect(launcher).toHaveAttribute("data-hg-ai-state", "error"); await expect(launcher).toHaveAttribute("title", /unavailable/i); @@ -107,6 +138,9 @@ test("AI pending timeout discards stale state and retry waits for a fresh bundle await page.goto(AI_ORIGIN + "/docs/"); const launcher = page.locator(".hg-ask-ai-launcher"); await launcher.click(); + await expect(page.locator("[data-hg-ai-consent]")).toBeVisible(); + expect(attempts).toBe(0); + await page.locator("[data-hg-ai-consent] [data-hg-ai-continue]").click(); await expect.poll(() => attempts).toBe(1); await expect(launcher).toHaveAttribute("data-hg-ai-state", "error", { timeout: 7_000 diff --git a/tests/e2e/artifact-test.js b/tests/e2e/artifact-test.js index 564a26f860..1b874a9885 100644 --- a/tests/e2e/artifact-test.js +++ b/tests/e2e/artifact-test.js @@ -15,16 +15,22 @@ const test = base.test.extend({ return; } const local = new URL(requested.pathname + requested.search, LOCAL_ARTIFACT_ORIGIN); - const response = await route.fetch({ url: local.href }); - await route.fulfill({ - response, - headers: { - ...response.headers(), - "access-control-allow-origin": "*" - } - }); + try { + const response = await route.fetch({ url: local.href }); + await route.fulfill({ + response, + headers: { + ...response.headers(), + "access-control-allow-origin": "*" + } + }); + } catch (error) { + if (String(error).includes("Test ended")) return; + throw error; + } }); await use(page); + await page.unrouteAll({ behavior: "ignoreErrors" }); } }); diff --git a/tests/e2e/platform.spec.js b/tests/e2e/platform.spec.js index 4507465708..e7945d6034 100644 --- a/tests/e2e/platform.spec.js +++ b/tests/e2e/platform.spec.js @@ -4,7 +4,7 @@ for (const locale of ["en", "cn"]) { const prefix = locale === "cn" ? "/cn" : ""; test(`latest ${locale} sidebar persists and isolates collapse`, async ({ page }) => { await page.goto(`${prefix}/docs/introduction/`); - const key = `oink.sidebar.v1.latest.${locale}`; + const key = `oink.sidebar.v2.latest.${locale}`; await expect.poll(() => page.evaluate((name) => localStorage.getItem(name), key)) .not.toBeNull(); const toggle = page @@ -25,6 +25,22 @@ for (const locale of ["en", "cn"]) { await expect(page.locator("#td-shell-sidebar")).toHaveJSProperty("inert", true); const restore = page.locator(".hg-sidebar-restore"); await expect(restore).toBeVisible(); + const edge = page.locator(".hg-sidebar-edge"); + const panel = page.locator(".td-shell-sidebar__panel"); + await page.waitForTimeout(200); + await edge.dispatchEvent("pointerenter", { pointerType: "mouse" }); + await expect(page.locator("#td-shell-sidebar")).toHaveClass( + /td-shell-sidebar--overlay/ + ); + const previewBox = await page.locator(".td-shell-sidebar__panel").boundingBox(); + expect(previewBox.x).toBeLessThanOrEqual(1); + expect(previewBox.y).toBeLessThanOrEqual(1); + await panel.dispatchEvent("pointerenter", { pointerType: "mouse" }); + await panel.dispatchEvent("pointerleave", { pointerType: "mouse" }); + await expect.poll( + () => page.locator("#td-shell-sidebar").getAttribute("class"), + { timeout: 1500 } + ).not.toContain("td-shell-sidebar--overlay"); await restore.click(); await expect(page.locator("#td-shell-sidebar")).not.toHaveAttribute( "aria-hidden", "true" @@ -44,6 +60,32 @@ for (const locale of ["en", "cn"]) { }); } +for (const locale of ["en", "cn"]) { + const prefix = locale === "cn" ? "/cn" : ""; + test(`latest ${locale} docs home opens start and components by default`, async ({ page }) => { + const key = `oink.sidebar.v2.latest.${locale}`; + await page.goto(`${prefix}/docs/`); + await page.evaluate((name) => localStorage.removeItem(name), key); + await page.reload(); + const start = page.locator( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navstart-children"]', + ); + const components = page.locator( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navcomponents-children"]', + ); + const develop = page.locator( + '#td-shell-sidebar [data-td-shell-tree-toggle][aria-controls$="_navdevelop-children"]', + ); + await expect(start).toHaveAttribute("aria-expanded", "true"); + await expect(components).toHaveAttribute("aria-expanded", "true"); + await expect(develop).toHaveAttribute("aria-expanded", "false"); + await start.click(); + await page.reload(); + await expect(page.locator(`[aria-controls="${await start.getAttribute("aria-controls")}"]`)) + .toHaveAttribute("aria-expanded", "false"); + }); +} + test("disabled AI emits no UI or Kapa request", async ({ page }) => { const kapaRequests = []; page.on("request", (request) => { @@ -104,7 +146,7 @@ test("Community grid and HTML/Print/Markdown profiles stay in parity", async ({ (await page.locator(".hg-community-members__grid").count()) === 0, "PR-B Community section is not integrated in this artifact" ); - for (const [width, columns] of [[1440, 5], [900, 3], [390, 2], [320, 2]]) { + for (const [width, columns] of [[1440, 4], [900, 3], [390, 2], [320, 2]]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/community/"); const grid = page.locator(".hg-community-members__grid").first(); @@ -117,6 +159,18 @@ test("Community grid and HTML/Print/Markdown profiles stay in parity", async ({ await page.reload(); await expect(page.locator(".hg-community-member__link").first()).toBeVisible(); await expect(page.locator(".hg-community-member__initials").first()).toBeAttached(); + await expect(page.locator(".hg-community-member__role-label")).toHaveCount(0); + expect(await page.locator(".hg-community-member__surface:not(.hg-community-member__link)").count()).toBeGreaterThan(0); + expect(await page.locator(".hg-community-member__surface:not(.hg-community-member__link) a").count()).toBe(0); + const publicNames = await page + .locator("#project-members .hg-community-member__identity") + .allTextContents(); + expect(publicNames).toContain("coderzc"); + expect(publicNames).toContain("Jacky Yang"); + expect(publicNames).toContain("Jermy Li"); + await expect(page.getByRole("link", { name: "coderzc on GitHub", exact: true })).toBeVisible(); + await page.goto("/cn/community/"); + await expect(page.getByRole("link", { name: "coderzc 的 GitHub 主页", exact: true })).toBeVisible(); const htmlProfiles = await page .locator("#project-members .hg-community-member__link") diff --git a/tests/e2e/versioning.spec.js b/tests/e2e/versioning.spec.js index d9a1dd751d..fc5b5707ad 100644 --- a/tests/e2e/versioning.spec.js +++ b/tests/e2e/versioning.spec.js @@ -166,6 +166,7 @@ test("historical selectors preserve the readme route across desktop, mobile, and const mobile = page.locator( "#td-shell-sidebar a[data-hg-version-id='1.3']" ); + await page.locator("#td-shell-sidebar .hg-version-overflow summary").click(); await mobile.click(); await expect(page).toHaveURL((url) => url.pathname === "/versions/1.3/cn/docs/introduction/readme/" && @@ -230,6 +231,7 @@ test("introduction aliases resolve bidirectionally without merging canonical pag await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/cn/docs/introduction/?query=history#overview"); await page.locator("[data-td-shell-drawer-open]").click(); + await page.locator("#td-shell-sidebar .hg-version-overflow summary").click(); await page .locator("#td-shell-sidebar a[data-hg-version-id='1.3']") .click(); diff --git a/tests/e2e/workflow-contract.test.cjs b/tests/e2e/workflow-contract.test.cjs index 236648bc42..9e6cbec0d2 100644 --- a/tests/e2e/workflow-contract.test.cjs +++ b/tests/e2e/workflow-contract.test.cjs @@ -37,3 +37,23 @@ test("only publish receives write permission", () => { ); assert.doesNotMatch(workflow, /write-all/); }); + +test("build consumers check out the immutable prepared source SHA", () => { + assert.match(workflow, /echo "source_sha=\$latest_sha"/); + const refs = [...workflow.matchAll(/^\s+ref: \$\{\{([^}]+)\}\}/gm)].map((match) => match[1]); + assert.equal(refs.length, 5); + assert.doesNotMatch(refs[0], /candidate_branch/); + assert.equal(refs.slice(1).filter((ref) => ref.includes("needs.prepare.outputs.source_sha")).length, 4); + assert.doesNotMatch(workflow, /test \"\$GITHUB_REF\" = \"refs\/heads\/\$candidate\"/); + assert.match(workflow, /test \"\$GITHUB_REF\" = \"refs\/heads\/master\"/); +}); + +test("dependency artifacts keep stable names across selective reruns", () => { + assert.match(workflow, /name: resolved-versions-\$\{\{ github\.run_id \}\}/); + assert.match(workflow, /name: \$\{\{ needs\.prepare\.outputs\.artifact_prefix \}\}-\$\{\{ matrix\.version\.id \}\}-\$\{\{ github\.run_id \}\}/); + assert.match(workflow, /pattern: \$\{\{ needs\.prepare\.outputs\.artifact_prefix \}\}-\*-\$\{\{ github\.run_id \}\}/); + assert.match(workflow, /--artifact-suffix="-\$\{GITHUB_RUN_ID\}"/); + assert.match(workflow, /name: hugegraph-site-\$\{\{ needs\.prepare\.outputs\.artifact_prefix \}\}-\$\{\{ github\.run_id \}\}/); + assert.doesNotMatch(workflow, /name: (?:resolved-versions|hugegraph-site-[^\n]+)-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); + assert.equal((workflow.match(/\n\s+overwrite: true/g) ?? []).length, 3); +}); diff --git a/tests/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs index c24e09e36e..997e1a8ab5 100644 --- a/tests/ui-ai/kapa-adapter.test.cjs +++ b/tests/ui-ai/kapa-adapter.test.cjs @@ -21,10 +21,25 @@ function harness() { textContent: '', classList: { toggle() {} }, }; + const consentListeners = new Map(); + const continueButton = { addEventListener(name, callback) { consentListeners.set(`continue:${name}`, callback); } }; + const cancelButton = { addEventListener(name, callback) { consentListeners.set(`cancel:${name}`, callback); } }; + const consent = { + open: false, + showModal() { this.open = true; }, + close() { this.open = false; }, + addEventListener(name, callback) { consentListeners.set(`dialog:${name}`, callback); }, + querySelector(selector) { + if (selector === '[data-hg-ai-continue]') return continueButton; + if (selector === '[data-hg-ai-cancel]') return cancelButton; + return null; + }, + }; const documentObject = { activeElement: trigger, querySelector(selector) { if (selector === '[data-hg-ai-status]') return status; + if (selector === '[data-hg-ai-consent]') return consent; if (selector === 'script[data-hg-kapa-widget]') { return scripts.find((script) => !script.removed) || null; } @@ -85,6 +100,10 @@ function harness() { documentObject, fireRender(index = renderCallbacks.length - 1) { renderCallbacks[index](); }, fireTimeout() { Array.from(timers.values()).forEach((callback) => callback()); }, + continueConsent() { consentListeners.get('continue:click')(); }, + cancelConsent() { consentListeners.get('cancel:click')(); }, + escapeConsent() { consentListeners.get('dialog:keydown')({ key: 'Escape', preventDefault() {}, stopPropagation() {} }); }, + nativeCancel() { consentListeners.get('dialog:cancel')({ preventDefault() {} }); }, installBundle() { const queued = windowObject.Kapa && Array.isArray(windowObject.Kapa.q) @@ -138,6 +157,7 @@ test('sends only the trimmed query after explicit activation and render', () => assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose']); controller.activate(' how to start? ', true, h.trigger); + h.continueConsent(); assert.equal(controller.getState(), 'loading'); assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose', 'render']); @@ -163,6 +183,7 @@ test('ignores duplicate activation and never opens after a late render', () => { h.config, ); controller.activate('first', true, h.trigger); + h.continueConsent(); controller.activate('second', true, h.trigger); assert.equal( h.calls.filter(([name]) => name === 'render').length, @@ -187,6 +208,7 @@ test('launcher opens a blank session without auto-submit', () => { h.config, ); controller.activate('', false, h.trigger); + h.continueConsent(); h.scripts[0].fire('load'); h.fireRender(); assert.deepEqual(h.calls.at(-1), [ @@ -195,6 +217,19 @@ test('launcher opens a blank session without auto-submit', () => { ]); }); +test('cancel, Escape, and native cancel keep Kapa unloaded and restore focus', () => { + for (const close of ['cancelConsent', 'escapeConsent', 'nativeCancel']) { + const h = harness(); + const controller = adapter.createController(h.windowObject, h.documentObject, h.config); + controller.activate('private question', true, h.trigger); + assert.equal(controller.getState(), 'consent'); + h[close](); + assert.equal(controller.getState(), 'idle'); + assert.equal(h.scripts.length, 0); + assert.equal(h.trigger.focused, true); + } +}); + test('a pending timeout retries with a fresh script and ignores the late attempt', () => { const h = harness(); const controller = adapter.createController( @@ -203,6 +238,7 @@ test('a pending timeout retries with a fresh script and ignores the late attempt h.config, ); controller.activate('first', true, h.trigger); + h.continueConsent(); assert.equal(h.scripts.length, 1); const staleRender = h.renderCallbacks[0]; @@ -232,3 +268,141 @@ test('a pending timeout retries with a fresh script and ignores the late attempt { mode: 'ai', query: 'second', submit: true }, ]); }); + +test('init succeeds without search shell and binds standalone triggers', () => { + const h = harness(); + const configNode = { + textContent: JSON.stringify({ + websiteId: 'test-id', + sourceGroupId: 'test-group', + locale: 'en', + themeColor: '#532fc9', + historical: false, + labels: { ask: 'Ask AI' }, + }), + }; + const doc = { + ...h.documentObject, + getElementById(id) { + if (id === 'hg-ai-config') return configNode; + if (id === 'td-shell-search') return null; + return null; + }, + }; + h.trigger.addEventListener = (name, cb) => {}; + const controller = adapter.init(h.windowObject, doc); + assert.ok(controller); + assert.equal(h.trigger.dataset.hgAiBound, ''); +}); + +test('init wires search shell Enter handler and updates noResults text', () => { + global.MutationObserver = class { + observe() {} + disconnect() {} + }; + const h = harness(); + h.trigger.addEventListener = (name, cb) => {}; + const configNode = { + textContent: JSON.stringify({ + websiteId: 'test-id', + sourceGroupId: 'test-group', + locale: 'en', + themeColor: '#532fc9', + historical: false, + labels: { ask: 'Ask AI', noResults: 'No documentation results found' }, + }), + }; + const emptyNode = { + className: 'td-shell-search__empty', + textContent: 'old empty', + }; + const tailBtn = { + dataset: { hgAskAi: '' }, + addEventListener() {}, + }; + const tailGroup = { + dataset: { hgAiSearchTail: '' }, + querySelector(sel) { + if (sel === '[data-hg-ask-ai]') return tailBtn; + return null; + }, + remove() {}, + }; + const list = { + className: 'td-shell-search__list', + querySelector(sel) { + if (sel === '.td-shell-search__empty') return emptyNode; + if (sel === '.td-shell-search__item:not(.hg-ai-search-tail__button)') return null; + if (sel === '[data-hg-ai-search-tail] [data-hg-ask-ai]') return tailBtn; + if (sel === '[data-hg-ai-search-tail]') return tailGroup; + return null; + }, + querySelectorAll(sel) { + if (sel === '.td-shell-search__empty') return [emptyNode]; + if (sel === '.td-shell-search__group-label') return []; + return []; + }, + appendChild() {}, + }; + const inputListeners = new Map(); + const input = { + value: 'graph query', + addEventListener(event, handler) { + inputListeners.set(event, handler); + }, + }; + const root = { + dataset: {}, + hidden: false, + querySelector(sel) { + if (sel === '.td-shell-search__input') return input; + if (sel === '.td-shell-search__list') return list; + return null; + }, + }; + const doc = { + ...h.documentObject, + getElementById(id) { + if (id === 'hg-ai-config') return configNode; + if (id === 'td-shell-search') return root; + return null; + }, + createElement(name) { + if (name === 'script') return h.documentObject.createElement('script'); + return { + className: '', + dataset: {}, + setAttribute() {}, + appendChild() {}, + addEventListener() {}, + }; + }, + }; + + const controller = adapter.init(h.windowObject, doc); + assert.ok(controller); + + // Assert empty node text was updated + assert.equal(emptyNode.textContent, 'No documentation results found'); + + // Trigger Enter on input + const keydown = inputListeners.get('keydown'); + assert.ok(keydown); + let prevented = false; + keydown({ + key: 'Enter', + preventDefault() { prevented = true; }, + stopImmediatePropagation() {}, + }); + assert.equal(prevented, true); + h.continueConsent(); + h.installBundle(); + h.scripts[0].fire('load'); + h.fireRender(); + assert.deepEqual(h.calls.at(-1), [ + 'open', + { mode: 'ai', query: 'graph query', submit: true }, + ]); +}); + + diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs index c67f12fe7b..d4d3c30cda 100644 --- a/tests/ui-ai/ui-contract.test.cjs +++ b/tests/ui-ai/ui-contract.test.cjs @@ -87,7 +87,7 @@ test('image zoom is limited to docs and blog', () => { test('shell persistence uses the version and locale scoped key', () => { const source = read('assets/js/hugegraph-shell.js'); - assert.match(source, /oink\.sidebar\.v1\./); + assert.match(source, /oink\.sidebar\.v2\./); assert.match(source, /config\.version/); assert.match(source, /config\.locale/); assert.match(source, /sidebar\.inert = isolated/); @@ -96,8 +96,8 @@ test('shell persistence uses the version and locale scoped key', () => { test('all three version selector surfaces expose one stable route contract', () => { const navbar = read('layouts/_partials/navbar.html'); const sidebar = read('layouts/_partials/shell/sidebar-panel.html'); - assert.equal((navbar.match(/partial "version-link\.html"/g) || []).length, 2); - assert.equal((sidebar.match(/partial "version-link\.html"/g) || []).length, 1); + assert.equal((navbar.match(/partial "version-menu-links\.html"/g) || []).length, 2); + assert.equal((sidebar.match(/partial "version-menu-links\.html"/g) || []).length, 1); assert.match( read('layouts/_partials/version-link.html'), /data-hg-version-id=/,