diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index ff1172e3e1..bceda227a2 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,6 +156,7 @@ 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 @@ -193,7 +198,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 @@ -259,7 +264,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-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: @@ -290,6 +295,10 @@ jobs: --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: @@ -309,7 +318,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: @@ -372,7 +381,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: 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..d6c371bb9f 100644 --- a/assets/js/hugegraph-shell.js +++ b/assets/js/hugegraph-shell.js @@ -126,12 +126,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 +221,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 +436,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..2c1035296e 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(); @@ -333,9 +383,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..2cf610cfd6 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -491,28 +491,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; @@ -645,7 +654,8 @@ gap: 0.35rem; > a, - > button { + > button, + > .hg-version-overflow > a { display: inline-flex; align-items: center; gap: 0.4rem; @@ -660,12 +670,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 +850,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..f5cfa240c4 --- /dev/null +++ b/assets/scss/community-members.scss @@ -0,0 +1,62 @@ +.hg-community-members { + --hg-community-columns: 5; + &__role + &__role { margin-top: 2.5rem; } + &__grid { + display: grid; + grid-template-columns: repeat(var(--hg-community-columns), minmax(0, 1fr)); + gap: 1rem; + padding: 0; + margin: 1rem 0 0; + list-style: none; + } +} +.hg-community-member { + min-width: 0; + padding: 0; + &__link { + display: flex; + min-height: 100%; + flex-direction: column; + align-items: center; + gap: .65rem; + padding: 1rem .75rem; + color: inherit; + text-align: center; + text-decoration: none; + border: 1px solid var(--td-border-color); + border-radius: var(--td-card-border-radius, .75rem); + background: var(--td-card-bg, var(--bs-body-bg)); + &:hover, &:focus-visible { + color: var(--td-link-color, var(--bs-link-color)); + border-color: currentColor; + } + } + &__avatar { + position: relative; + display: grid; + width: 8rem; + height: 8rem; + 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: 1.65rem; + font-weight: 700; + color: var(--td-body-color, var(--bs-body-color)); + } + &__identity { max-width: 100%; overflow-wrap: anywhere; font-weight: 650; } + &__role-label { font-size: .8rem; color: var(--td-secondary-color, var(--bs-secondary-color)); } +} +@media (max-width: 1199.98px) { + .hg-community-members { --hg-community-columns: 3; } +} +@media (max-width: 767.98px) { + .hg-community-members { --hg-community-columns: 2; } + .hg-community-member__avatar { width: 6rem; height: 6rem; } +} 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..e7852af765 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,7 +42,7 @@ 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 @@ -58,7 +59,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Request Body -```json +```json {filename="request.json" wrap=true} { "label": "person", "properties": { @@ -76,7 +77,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Response Body -```json +```json {filename="response.json" wrap=true} { "id": "1:marko", "label": "person", 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..72a07c23de 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/ 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..a159356f28 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,7 +42,7 @@ 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 @@ -51,7 +52,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Request Body -```json +```json {filename="request.json" wrap=true} { "label": "person", "properties": { @@ -69,7 +70,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Response Body -```json +```json {filename="response.json" wrap=true} { "id": "1:marko", "label": "person", 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..87b87f30d8 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/ 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..7529e5a32d --- /dev/null +++ b/data/community/github-map.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "mappings": {} +} diff --git a/data/community/roster.json b/data/community/roster.json new file mode 100644 index 0000000000..413853d88f --- /dev/null +++ b/data/community/roster.json @@ -0,0 +1,209 @@ +{ + "schema_version": 1, + "project": "hugegraph", + "retrieved_at": "2026-09-04T12:12:42Z", + "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://people.apache.org/phonebook.html?uid=jermy" + }, + { + "asf_id": "zhaocong", + "name": "Cong Zhao", + "initials": "CZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhaocong" + }, + { + "asf_id": "jin", + "name": "Imba Jin", + "initials": "IJ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=jin" + }, + { + "asf_id": "panjuan", + "name": "Juan Pan", + "initials": "JP", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=panjuan" + }, + { + "asf_id": "lidongdai", + "name": "Lidong Dai", + "initials": "LD", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=lidongdai" + }, + { + "asf_id": "linary", + "name": "NingRui Li", + "initials": "NL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=linary" + }, + { + "asf_id": "ming", + "name": "Simon", + "initials": "S", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=ming" + }, + { + "asf_id": "ningjiang", + "name": "Willem Ning Jiang", + "initials": "WN", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=ningjiang" + }, + { + "asf_id": "hxd", + "name": "Xiangdong Huang", + "initials": "XH", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=hxd" + }, + { + "asf_id": "vaughn", + "name": "Yan Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vaughn" + }, + { + "asf_id": "liyu", + "name": "Yu Li", + "initials": "YL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=liyu" + }, + { + "asf_id": "vgalaxies", + "name": "Yuchen Ding", + "initials": "YD", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vgalaxies" + } + ], + "committers": [ + { + "asf_id": "yangjiaqi", + "name": "Jacky Yang", + "initials": "JY", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=yangjiaqi" + }, + { + "asf_id": "jsong010123", + "name": "Jason", + "initials": "J", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=jsong010123" + }, + { + "asf_id": "wangjing", + "name": "Jing Wang", + "initials": "JW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=wangjing" + }, + { + "asf_id": "pengjunzhi", + "name": "Junzhi Peng", + "initials": "JP", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=pengjunzhi" + }, + { + "asf_id": "vichayturen", + "name": "Kaiyichen Wei", + "initials": "KW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vichayturen" + }, + { + "asf_id": "leizou", + "name": "Lei Zou", + "initials": "LZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=leizou" + }, + { + "asf_id": "guoshoujing", + "name": "Shoujing Guo", + "initials": "SG", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=guoshoujing" + }, + { + "asf_id": "liuxiaocs", + "name": "Xiao Liu", + "initials": "XL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=liuxiaocs" + }, + { + "asf_id": "zhangyi89817", + "name": "Yi Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhangyi89817" + }, + { + "asf_id": "spica", + "name": "Zhe Wang", + "initials": "ZW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=spica" + } + ] + } +} 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/landing/community/cn.yaml b/data/landing/community/cn.yaml index 9bf9f66f4c..ec95ae75a2 100644 --- a/data/landing/community/cn.yaml +++ b/data/landing/community/cn.yaml @@ -34,6 +34,11 @@ sections: - [安全策略](/cn/docs/guides/security/) — 了解项目的安全问题报告流程。 - [贡献指南](/cn/docs/contribution-guidelines/) — 了解如何贡献代码和文档。 + - type: community-members + partial: landing/sections/community-members.html + data: + id: project-members + - type: markdown data: title: 了解项目运作方式 diff --git a/data/landing/community/en.yaml b/data/landing/community/en.yaml index 9f54604f72..d5b34325e2 100644 --- a/data/landing/community/en.yaml +++ b/data/landing/community/en.yaml @@ -34,6 +34,11 @@ sections: - [Security policy](/docs/guides/security/) — follow the project's security reporting process. - [Contribution guidelines](/docs/contribution-guidelines/) — learn how to contribute code and documentation. + - type: community-members + partial: landing/sections/community-members.html + data: + id: project-members + - type: cta data: title: Learn how the project works diff --git a/i18n/en.yaml b/i18n/en.yaml index 4c0e1c29af..aceb7fea29 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -4,3 +4,24 @@ ui_ask_ai_description: Powered by Kapa; only your question is sent. ui_ask_ai_latest: Answers use the latest documentation 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: All packages on this page are official Apache Software Foundation releases 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..4f9757ab43 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -240,3 +240,23 @@ ui_ask_ai_description: 由 Kapa 提供;仅发送你的问题。 ui_ask_ai_latest: 回答基于 latest 文档 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..459604b1a8 --- /dev/null +++ b/layouts/_partials/community/members.html @@ -0,0 +1,37 @@ +{{- $page := .page -}} +{{- $data := hugo.Data.community.roster -}} +{{- $labels := dict + "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from public ASF records." "chair" "Chair" "pmc" "PMC" "committers" "Committers") + "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 公开记录。" "chair" "主席" "pmc" "PMC" "committers" "Committers") +-}} +{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}} +{{- $style := resources.Get "scss/community-members.scss" | toCSS | minify | fingerprint -}} + +
+
+
+

{{ $copy.title }}

+

{{ $copy.lead }}

+
+ {{- 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..955aaee81a --- /dev/null +++ b/layouts/_partials/community/members.md @@ -0,0 +1,23 @@ +{{- $page := .page -}} +{{- $data := hugo.Data.community.roster -}} +{{- $labels := dict + "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from public ASF records." "chair" "Chair") + "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 公开记录。" "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 := .asf_id -}} +{{- with .github }}{{ $label = printf "@%s" .login }}{{ end -}} +{{- $label = partial "content/markdown-escape.html" $label -}} +{{- $url := partial "content/markdown-url.html" .profile_url -}} +- [{{ $label }}]({{ $url }}){{ if .chair }} — {{ $copy.chair }}{{ end }} +{{ end }} + +{{ end -}} diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html index bca33d724a..c57acf63f3 100644 --- a/layouts/_partials/hooks/body-end.html +++ b/layouts/_partials/hooks/body-end.html @@ -37,13 +37,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..d317dbc724 --- /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 and link +to the ASF phonebook 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..6ec17c20d2 --- /dev/null +++ b/scripts/community_roster.py @@ -0,0 +1,714 @@ +#!/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") + 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 _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} + pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: _sort_key(item, names)) + committer_ids = sorted(member_ids - owner_ids, key=lambda item: _sort_key(item, names)) + 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") + for role, entries in roles.items(): + tail = entries[1:] if role == "pmc" else entries + actual_order = [(p["name"].casefold(), p["asf_id"].casefold()) for p in tail] + if actual_order != sorted(actual_order): + raise RosterError(f"roster.json: {role} must be sorted by public name and ASF ID casefold") + mappings = _validate_mapping(mapping, set(ids)) + for person in people: + 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": []} + segments = { + "pmc": rendered[starts["pmc"] : starts["committers"]], + "committers": rendered[starts["committers"] :], + } + 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(): + expected_links = [person["profile_url"] for person in entries] + 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..c50c0fcc7f --- /dev/null +++ b/scripts/test_community_roster.py @@ -0,0 +1,785 @@ +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) + + +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_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()) + 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(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, "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()) + asf_id = base["roles"]["committers"][0]["asf_id"] + mapping = {"schema_version": 1, "mappings": {asf_id: {"login": "valid-user", "user_id": 1}}} + 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 = json.loads(roster.MAP_PATH.read_text()) + 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 = json.loads(roster.MAP_PATH.read_text()) + 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)) + 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", + "## Get involved", + "## Project members", + "## 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/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/platform.spec.js b/tests/e2e/platform.spec.js index 4507465708..55db059fe4 100644 --- a/tests/e2e/platform.spec.js +++ b/tests/e2e/platform.spec.js @@ -25,6 +25,19 @@ 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/ + ); + 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" 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..7bfc8cc4e4 100644 --- a/tests/e2e/workflow-contract.test.cjs +++ b/tests/e2e/workflow-contract.test.cjs @@ -37,3 +37,13 @@ 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\"/); +}); diff --git a/tests/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs index c24e09e36e..2c58a2b665 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]; diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs index c67f12fe7b..718a711949 100644 --- a/tests/ui-ai/ui-contract.test.cjs +++ b/tests/ui-ai/ui-contract.test.cjs @@ -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=/,