diff --git a/.github/workflows/browser-quality.yml b/.github/workflows/browser-quality.yml
index 507c05a..46547ad 100644
--- a/.github/workflows/browser-quality.yml
+++ b/.github/workflows/browser-quality.yml
@@ -13,7 +13,8 @@ env:
HUGO_VERSION: 0.164.0
NODE_VERSION: 24
HUGO_CACHEDIR: ${{ github.workspace }}/.hugo_cache
- GOMODCACHE: ${{ github.workspace }}/.hugo_cache/modules/filecache/modules/pkg/mod
+ GOMODCACHE:
+ ${{ github.workspace }}/.hugo_cache/modules/filecache/modules/pkg/mod
jobs:
axe:
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 62b07b5..3dbc853 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -18,7 +18,8 @@ env:
GO_VERSION: 1.25.5
HUGO_VERSION: 0.164.0
HUGO_CACHEDIR: ${{ github.workspace }}/.hugo_cache
- GOMODCACHE: ${{ github.workspace }}/.hugo_cache/modules/filecache/modules/pkg/mod
+ GOMODCACHE:
+ ${{ github.workspace }}/.hugo_cache/modules/filecache/modules/pkg/mod
jobs:
build:
diff --git a/content/blog/_index.md b/content/blog/_index.md
index a194eae..6ee33ed 100644
--- a/content/blog/_index.md
+++ b/content/blog/_index.md
@@ -2,7 +2,7 @@
downstream_modified: true
title: Blog
description: Docsy articles, OINK engineering stories, and OINK release notes
-menu: { main: { weight: 50 } }
+search_keywords: [oink news, release notes, docsy]
type: blog
icon: fa-solid fa-blog
sidebar_root_for: self
@@ -10,6 +10,7 @@ sidebar_root_link_self: true
comments: false
cascade:
type: blog
+ search_boost: 0.9
params:
ui:
sidebar_menu_foldable: false
diff --git a/content/blog/_index.zh.md b/content/blog/_index.zh.md
index 195c91c..aa4072d 100644
--- a/content/blog/_index.zh.md
+++ b/content/blog/_index.zh.md
@@ -1,7 +1,7 @@
---
title: 博客
description: Docsy 文章、OINK 工程实践与 OINK 发布注记
-menu: { main: { weight: 50 } }
+search_keywords: [OINK 新闻, 版本说明, Docsy]
type: blog
icon: fa-solid fa-blog
sidebar_root_for: self
@@ -9,6 +9,7 @@ sidebar_root_link_self: true
comments: false
cascade:
type: blog
+ search_boost: 0.9
params:
ui:
sidebar_menu_foldable: false
diff --git a/content/docs/_index.md b/content/docs/_index.md
index 899b81c..eb25158 100644
--- a/content/docs/_index.md
+++ b/content/docs/_index.md
@@ -3,13 +3,15 @@ downstream_modified: true
title: Welcome to OINK
linkTitle: Docs
description: Install, customize, deploy, and maintain Oink documentation sites.
-menu: { main: { weight: 20 } }
+search_keywords: [oink, hugo theme, engineering documentation]
type: docs
icon: fa-solid fa-book
sidebar_expanded: true
sidebar_root_for: self
sidebar_root_link_self: true
comments: false
+cascade:
+ search_boost: 1.35
---
diff --git a/content/docs/_index.zh.md b/content/docs/_index.zh.md
index 6f116a5..6592798 100644
--- a/content/docs/_index.zh.md
+++ b/content/docs/_index.zh.md
@@ -2,13 +2,15 @@
title: 欢迎使用 OINK
linkTitle: 文档
description: 安装、定制、部署与维护 Oink 文档站。
-menu: { main: { weight: 20 } }
+search_keywords: [OINK, Hugo 主题, 工程文档]
type: docs
icon: fa-solid fa-book
sidebar_expanded: true
sidebar_root_for: self
sidebar_root_link_self: true
comments: false
+cascade:
+ search_boost: 1.35
---
diff --git a/content/docs/content/configuration.md b/content/docs/content/configuration.md
index 1901fe2..7b063ed 100644
--- a/content/docs/content/configuration.md
+++ b/content/docs/content/configuration.md
@@ -3,6 +3,8 @@ title: Configuration
weight: 10
icon: fa-solid fa-sliders
description: Configure Oink with Hugo settings and focused theme parameters.
+search_keywords: [config, settings, params, yaml]
+search_boost: 1.6
aliases: [/docs/oink/configuration/]
---
diff --git a/content/docs/content/configuration.zh.md b/content/docs/content/configuration.zh.md
index 2e26a93..c2f587c 100644
--- a/content/docs/content/configuration.zh.md
+++ b/content/docs/content/configuration.zh.md
@@ -3,6 +3,8 @@ title: 配置
weight: 10
icon: fa-solid fa-sliders
description: 使用 Hugo 设置与职责明确的主题参数配置 Oink。
+search_keywords: [配置, 设置, 参数, YAML]
+search_boost: 1.6
aliases: [/docs/oink/configuration/]
---
diff --git a/content/project/_index.md b/content/project/_index.md
index ea902ac..02b1407 100644
--- a/content/project/_index.md
+++ b/content/project/_index.md
@@ -1,10 +1,17 @@
---
title: Oink project and website documentation
-linkTitle: Project docs
+linkTitle: Project
description: How the Oink theme and website are built, maintained, and deployed.
+search_keywords: [architecture, maintenance, deployment, repository]
aliases: [site]
+type: docs
+icon: fa-solid fa-diagram-project
+sidebar_root_for: self
+sidebar_root_link_self: true
+comments: false
cascade:
type: docs
+ search_boost: 1.15
params:
hide_feedback: true
cSpell:ignore: docsydocs
diff --git a/content/project/_index.zh.md b/content/project/_index.zh.md
new file mode 100644
index 0000000..a7efc1e
--- /dev/null
+++ b/content/project/_index.zh.md
@@ -0,0 +1,37 @@
+---
+title: OINK 项目与站点文档
+linkTitle: 项目
+description: OINK 主题与项目站的构建、维护和部署说明。
+search_keywords: [架构, 维护, 部署, 代码仓库]
+aliases: [site]
+type: docs
+icon: fa-solid fa-diagram-project
+sidebar_root_for: self
+sidebar_root_link_self: true
+comments: false
+cascade:
+ type: docs
+ search_boost: 1.15
+ params:
+ hide_feedback: true
+---
+
+
+{{% _param FAS person-digging " pe-2" %}} 本节仍在建设中。 {{%
+_param FAS person-digging " ps-2" %}}
+
+
+## 规划内容 {#content}
+
+当前规划的内容结构如下:
+
+- [关于项目](about/):项目目标、所有权和当前状态等概要信息。
+- **设计**:架构、信息架构、布局、用户体验与主题设计决策。
+- [实现](implementation/):代码结构与约定、Hugo 模板、SCSS/JS 定制、补丁和内部兼容层。
+- [构建](build/):本地开发、CI/CD、部署环境和自动化工具。
+- **质量**:链接检查、无障碍标准、测试与评审实践。
+- **路线图**:里程碑、待办事项、优先级、技术债与设计决策。
+
+## 站点构建信息 {#site-build-information}
+
+OINK 版本:`{{% dev-version %}}`
diff --git a/content/tests/alerts.md b/content/tests/alerts.md
index 6480c36..ab87490 100644
--- a/content/tests/alerts.md
+++ b/content/tests/alerts.md
@@ -1,5 +1,6 @@
---
title: Alerts
+search_exclude: true
cSpell:ignore: thumbsup
---
diff --git a/content/tests/code-blocks.md b/content/tests/code-blocks.md
index 3118314..873dae6 100644
--- a/content/tests/code-blocks.md
+++ b/content/tests/code-blocks.md
@@ -1,5 +1,6 @@
---
title: Enhanced code blocks
+exclude_search: true
description:
Browser and output regression fixtures for code blocks and Code Groups.
weight: 20
diff --git a/content/tests/layouts/no-left-sidebar.md b/content/tests/layouts/no-left-sidebar.md
index 791fffc..5c9a1c4 100644
--- a/content/tests/layouts/no-left-sidebar.md
+++ b/content/tests/layouts/no-left-sidebar.md
@@ -1,6 +1,7 @@
---
title: No left sidebar
type: docs
+excludeSearch: true
params:
body_class: td-no-left-sidebar
---
diff --git a/go.mod b/go.mod
index 5475645..e0df90b 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,4 @@ module github.com/pgsty/oink.pgsty.com
go 1.21
-require github.com/pgsty/oink v0.2.2-0.20260812031808-d945cbc854f4
+require github.com/pgsty/oink v0.2.2-0.20260812083702-9fc1d5ea50c8
diff --git a/go.sum b/go.sum
index 87bf764..49c70e7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,2 @@
-github.com/pgsty/oink v0.2.2-0.20260812031808-d945cbc854f4 h1:dPCsBe4i5nenCcQwwtsjc7bSuxIUuzScZmSGqkuRICc=
-github.com/pgsty/oink v0.2.2-0.20260812031808-d945cbc854f4/go.mod h1:wHzQR6qVOx5rgkDCvXBZhmsDrAb3O+0CRNBHyIzsYT0=
+github.com/pgsty/oink v0.2.2-0.20260812083702-9fc1d5ea50c8 h1:GOEJQQgpO4p841g3bGLRXgZVy1nLXbR9sZXbA2H7m8g=
+github.com/pgsty/oink v0.2.2-0.20260812083702-9fc1d5ea50c8/go.mod h1:wHzQR6qVOx5rgkDCvXBZhmsDrAb3O+0CRNBHyIzsYT0=
diff --git a/hugo.yml b/hugo.yml
index 1042387..de32067 100644
--- a/hugo.yml
+++ b/hugo.yml
@@ -43,6 +43,101 @@ languages:
giscus:
ariaLabel: Comments
errorMessage: Comments could not be loaded. Please try again later.
+ ui:
+ command_palette:
+ commands:
+ - id: theme_issues
+ title: OINK issues
+ description: Report or review theme and documentation issues
+ url: https://github.com/pgsty/oink/issues
+ icon: fa-brands fa-github
+ keywords: [bug, support, roadmap]
+ - id: copy_source
+ title: Copy page Markdown
+ description: Copy the current page source as Markdown
+ action: copy_markdown
+ icon: fa-regular fa-copy
+ keywords: [copy, source, markdown]
+ - id: print_page
+ title: Print this page
+ description: Open the browser print dialog
+ action: print
+ icon: fa-solid fa-print
+ keywords: [paper, pdf, print]
+ - id: choose_theme
+ title: Choose color theme
+ description: Select automatic, light, or dark appearance
+ action: switch_theme
+ icon: fa-solid fa-circle-half-stroke
+ keywords: [dark, light, appearance]
+ menus:
+ main:
+ - identifier: docs
+ name: Docs
+ pageRef: /docs
+ weight: 20
+ - identifier: docs-tutorial
+ parent: docs
+ name: Tutorials
+ pageRef: /docs/tutorial
+ weight: 10
+ params:
+ icon: fa-solid fa-route
+ description: Install OINK and build your first documentation site
+ - identifier: docs-content
+ parent: docs
+ name: Content
+ pageRef: /docs/content
+ weight: 20
+ params:
+ icon: fa-solid fa-pen-ruler
+ description: Author pages, components, diagrams, and API references
+ - identifier: docs-advanced
+ parent: docs
+ name: Advanced
+ pageRef: /docs/advanced
+ weight: 30
+ params:
+ icon: fa-solid fa-gears
+ description: Configure integrations, deployment, and versioning
+ - identifier: blog
+ name: Blog
+ pageRef: /blog
+ weight: 50
+ - identifier: blog-oink
+ parent: blog
+ name: OINK engineering
+ pageRef: /blog/oink
+ weight: 10
+ params:
+ icon: fa-solid fa-screwdriver-wrench
+ description: Design notes and engineering reports from the theme
+ - identifier: blog-release
+ parent: blog
+ name: Releases
+ pageRef: /blog/release
+ weight: 20
+ params:
+ icon: fa-solid fa-tags
+ description: Version announcements and migration notes
+ - identifier: project
+ name: Project
+ pageRef: /project
+ weight: 70
+ params:
+ icon: fa-solid fa-diagram-project
+ - identifier: issues
+ name: Issues
+ url: https://github.com/pgsty/oink/issues
+ weight: 80
+ params:
+ icon: fa-regular fa-circle-question
+ - identifier: github
+ name: GitHub
+ url: https://github.com/pgsty/oink
+ weight: 90
+ params:
+ icon: fa-brands fa-github
zh:
label: 简体中文
locale: zh-CN
@@ -92,6 +187,32 @@ languages:
icon: fa-brands fa-github
desc: OINK 在这里开放开发
ui:
+ command_palette:
+ commands:
+ - id: print_page
+ title: 打印当前页面
+ description: 打开浏览器打印对话框
+ action: print
+ icon: fa-solid fa-print
+ keywords: [打印, 纸张, PDF]
+ - id: theme_issues
+ title: OINK 问题反馈
+ description: 报告或查看主题与文档问题
+ url: https://github.com/pgsty/oink/issues
+ icon: fa-brands fa-github
+ keywords: [缺陷, 支持, 路线图]
+ - id: choose_theme
+ title: 选择颜色主题
+ description: 选择自动、浅色或深色外观
+ action: switch_theme
+ icon: fa-solid fa-circle-half-stroke
+ keywords: [深色, 浅色, 外观]
+ - id: copy_source
+ title: 复制页面 Markdown
+ description: 将当前页面源码复制为 Markdown
+ action: copy_markdown
+ icon: fa-regular fa-copy
+ keywords: [复制, 源码, Markdown]
feedback:
yes: >-
很高兴本页对你有帮助!欢迎-
很抱歉本页没有解决问题。请告诉我们缺少什么。
+ menus:
+ main:
+ - identifier: docs
+ name: 文档
+ pageRef: /docs
+ weight: 20
+ - identifier: docs-tutorial
+ parent: docs
+ name: 教程
+ pageRef: /docs/tutorial
+ weight: 10
+ params:
+ icon: fa-solid fa-route
+ description: 安装 OINK,并创建你的第一个文档站
+ - identifier: docs-content
+ parent: docs
+ name: 内容创作
+ pageRef: /docs/content
+ weight: 20
+ params:
+ icon: fa-solid fa-pen-ruler
+ description: 编写页面、组件、图表与 API 参考
+ - identifier: docs-advanced
+ parent: docs
+ name: 高级功能
+ pageRef: /docs/advanced
+ weight: 30
+ params:
+ icon: fa-solid fa-gears
+ description: 配置集成、部署与版本管理
+ - identifier: blog
+ name: 博客
+ pageRef: /blog
+ weight: 50
+ - identifier: blog-oink
+ parent: blog
+ name: OINK 工程实践
+ pageRef: /blog/oink
+ weight: 10
+ params:
+ icon: fa-solid fa-screwdriver-wrench
+ description: 主题设计记录与工程报告
+ - identifier: blog-release
+ parent: blog
+ name: 版本发布
+ pageRef: /blog/release
+ weight: 20
+ params:
+ icon: fa-solid fa-tags
+ description: 版本公告与迁移说明
+ - identifier: project
+ name: 项目
+ pageRef: /project
+ weight: 70
+ params:
+ icon: fa-solid fa-diagram-project
+ - identifier: issues
+ name: 问题反馈
+ url: https://github.com/pgsty/oink/issues
+ weight: 80
+ params:
+ icon: fa-regular fa-circle-question
+ - identifier: github
+ name: GitHub
+ url: https://github.com/pgsty/oink
+ weight: 90
+ params:
+ icon: fa-brands fa-github
markup:
tableOfContents:
endLevel: 4
@@ -182,7 +371,10 @@ params:
search:
# algolia:
ui:
- quick_links: [docs, blog]
+ quick_links: [docs, blog, project]
+ # Starter sites use the quieter group-only policy explicitly. The theme
+ # keeps its pre-1.0 compatibility default (`all`) when this is omitted.
+ sidebar_icon_policy: groups
page_context_menu:
enable: true
links: []
diff --git a/package.json b/package.json
index 14b6a67..92a59e2 100644
--- a/package.json
+++ b/package.json
@@ -34,13 +34,14 @@
"test:alt-site": "node --test 'tests/alt-site/**/*.test.mjs'",
"test:base": "npm run seq -- build _test:base",
"test:browser": "npm run seq -- test:a11y test:responsive-shell test:content-components test:code-blocks",
+ "test:command-palette": "playwright test tests/browser/command-palette.spec.mjs",
"test:code-blocks": "playwright test tests/browser/code-blocks.spec.mjs",
"test:content-components": "playwright test tests/browser/content-components.spec.mjs",
"test:extra": "npm run seq -- test:hugo-build test:alt-site test:md-output test:favicons",
"test:favicons": "node --test 'tests/favicons/**/*.test.mjs'",
"test:hugo-build": "node --test 'tests/hugo-build/**/*.test.mjs'",
"test:md-output": "node --test 'tests/md-output/**/*.test.mjs'",
- "test:responsive-shell": "playwright test tests/browser/responsive-shell.spec.mjs tests/browser/blog.spec.mjs",
+ "test:responsive-shell": "playwright test tests/browser/responsive-shell.spec.mjs tests/browser/blog.spec.mjs tests/browser/command-palette.spec.mjs",
"test": "npm run seq -- test:base test:extra",
"update:favicon-goldens": "node tests/favicons/update-goldens.mjs",
"update:goldens": "npm run _update:goldens",
diff --git a/playwright.config.mjs b/playwright.config.mjs
index dc22888..c71c2fe 100644
--- a/playwright.config.mjs
+++ b/playwright.config.mjs
@@ -3,7 +3,9 @@ import { defineConfig } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173';
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
-const moduleWorkspace = existsSync('go.work') ? 'go.work' : undefined;
+const moduleWorkspace =
+ process.env.HUGO_MODULE_WORKSPACE ||
+ (existsSync('go.work') ? 'go.work' : undefined);
export default defineConfig({
testDir: './tests/browser',
diff --git a/tests/alt-site/offline-search.test.mjs b/tests/alt-site/offline-search.test.mjs
index 0690f5b..90a30aa 100644
--- a/tests/alt-site/offline-search.test.mjs
+++ b/tests/alt-site/offline-search.test.mjs
@@ -4,69 +4,159 @@ import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { gzipSync } from 'node:zlib';
const siteDir = fileURLToPath(new URL('../../', import.meta.url));
-const moduleWorkspace = join(siteDir, 'go.work');
+const localWorkspace = join(siteDir, 'go.work');
+const moduleWorkspace =
+ process.env.HUGO_MODULE_WORKSPACE ||
+ (existsSync(localWorkspace) ? localWorkspace : undefined);
+const rawBudget = 2 * 1024 * 1024;
+const gzipBudget = 512 * 1024;
+const requiredFields = [
+ 'ref',
+ 'title',
+ 'categories',
+ 'tags',
+ 'excerpt',
+ 'headings',
+ 'description',
+ 'root',
+ 'section',
+ 'type',
+ 'keywords',
+ 'boost',
+ 'breadcrumb',
+ 'icon',
+];
+
+function entryBySuffix(entries, suffix) {
+ const matches = entries.filter((entry) => entry.ref.endsWith(suffix));
+ assert.equal(matches.length, 1, `Expected one index entry ending ${suffix}`);
+ return matches[0];
+}
// Build the site in a non-production environment -- `params.offlineSearch` is
// on in `hugo.yml`, and a non-production build leaves the index filenames
// un-fingerprinted -- then validate the generated language-specific indexes.
// This guards the page collection used by
// theme/assets/json/offline-search-index.json.
-test('offline-search index covers all site languages', (t) => {
- // Scratch space, kept after the run for inspection; cleared at start.
- const outDir = join(siteDir, 'tmp', 'offline-search');
- rmSync(outDir, { recursive: true, force: true });
+for (const [deployment, baseURL, prefix] of [
+ ['root', 'https://example.test/', ''],
+ ['subpath', 'https://example.test/preview/', '/preview'],
+]) {
+ test(`offline-search index covers all site languages under ${deployment}`, (t) => {
+ // Scratch space, kept after the run for inspection; cleared at start.
+ const outDir = join(siteDir, 'tmp', `offline-search-${deployment}`);
+ rmSync(outDir, { recursive: true, force: true });
- const res = spawnSync(
- `npm run _hugo -- -e dev -DFE ` +
- `--baseURL http://localhost -d ${outDir} --noBuildLock`,
- {
- cwd: siteDir,
- shell: true,
- encoding: 'utf8',
- env: {
- ...process.env,
- ...(existsSync(moduleWorkspace)
- ? { HUGO_MODULE_WORKSPACE: moduleWorkspace }
- : {}),
+ const res = spawnSync(
+ `npm run _hugo -- -e dev -DFE ` +
+ `--baseURL ${baseURL} -d ${outDir} --noBuildLock`,
+ {
+ cwd: siteDir,
+ shell: true,
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ...(moduleWorkspace
+ ? { HUGO_MODULE_WORKSPACE: moduleWorkspace }
+ : {}),
+ },
},
- },
- );
- const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
- assert.equal(res.status, 0, `Build failed:\n${output}`);
-
- for (const [language, ref] of [
- ['en', '/docs/'],
- ['zh', '/zh/docs/'],
- ]) {
- const indexPath = join(outDir, `offline-search-index.${language}.json`);
- assert.ok(existsSync(indexPath), `Missing ${indexPath}`);
- const entries = JSON.parse(readFileSync(indexPath, 'utf8'));
- assert.ok(Array.isArray(entries), `${language} index is not a JSON array`);
- assert.ok(
- entries.length > 80,
- `Suspiciously few ${language} index entries: ${entries.length}`,
- );
- for (const key of ['ref', 'title', 'description', 'headings', 'excerpt']) {
- assert.ok(key in entries[0], `${language} entries lack "${key}"`);
- }
- assert.ok(
- !('body' in entries[0]),
- `${language} summary entries unexpectedly contain full page bodies`,
- );
- assert.ok(
- entries.some((e) => e.ref === ref),
- `Index lacks an entry for ${ref}`,
);
- assert.ok(
- entries.every((entry) =>
+ const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
+ assert.equal(res.status, 0, `Build failed:\n${output}`);
+
+ for (const [language, ref] of [
+ ['en', `${prefix}/docs/`],
+ ['zh', `${prefix}/zh/docs/`],
+ ]) {
+ const indexPath = join(outDir, `offline-search-index.${language}.json`);
+ assert.ok(existsSync(indexPath), `Missing ${indexPath}`);
+ const entries = JSON.parse(readFileSync(indexPath, 'utf8'));
+ assert.ok(
+ Array.isArray(entries),
+ `${language} index is not a JSON array`,
+ );
+ assert.ok(
+ entries.length > 80,
+ `Suspiciously few ${language} index entries: ${entries.length}`,
+ );
+ for (const key of requiredFields) {
+ assert.ok(key in entries[0], `${language} entries lack "${key}"`);
+ }
+ assert.ok(
+ !('body' in entries[0]),
+ `${language} summary entries unexpectedly contain full page bodies`,
+ );
+ assert.ok(
+ entries.some((e) => e.ref === ref),
+ `Index lacks an entry for ${ref}`,
+ );
+ assert.ok(
+ entries.every((entry) =>
+ language === 'zh'
+ ? entry.ref.startsWith(`${prefix}/zh/`)
+ : entry.ref.startsWith(`${prefix}/`) &&
+ !entry.ref.startsWith(`${prefix}/zh/`),
+ ),
+ `${language} index contains another language`,
+ );
+
+ const payload = readFileSync(indexPath);
+ assert.ok(
+ payload.length <= rawBudget,
+ `${language} raw index exceeds budget`,
+ );
+ const compressed = gzipSync(payload, { mtime: 0 });
+ assert.ok(
+ compressed.length <= gzipBudget,
+ `${language} gzip index exceeds budget`,
+ );
+
+ const config = entryBySuffix(entries, '/docs/content/configuration/');
+ const tutorial = entryBySuffix(entries, '/docs/tutorial/install/');
+ const blog = entryBySuffix(entries, '/blog/oink/oink-announcement/');
+ const project = entryBySuffix(entries, '/project/build/ci-cd/');
+ assert.equal(config.boost, 1.6);
+ assert.equal(tutorial.boost, 1.35);
+ assert.equal(blog.boost, 0.9);
+ assert.equal(project.boost, 1.15);
+ assert.deepEqual(
+ config.keywords,
language === 'zh'
- ? entry.ref.startsWith('/zh/')
- : !entry.ref.startsWith('/zh/'),
- ),
- `${language} index contains another language`,
- );
- t.diagnostic(`${language} index entries: ${entries.length}`);
- }
-});
+ ? ['配置', '设置', '参数', 'YAML']
+ : ['config', 'settings', 'params', 'yaml'],
+ );
+ assert.deepEqual(
+ config.breadcrumb,
+ language === 'zh'
+ ? ['文档', '创作内容', '配置']
+ : ['Docs', 'Authoring', 'Configuration'],
+ );
+ assert.deepEqual(
+ [config.root, blog.root, project.root],
+ ['docs', 'blog', 'project'],
+ );
+ assert.ok(
+ entries.every(
+ (entry) =>
+ ![
+ '/tests/alerts/',
+ '/tests/code-blocks/',
+ '/tests/layouts/no-left-sidebar/',
+ ].some((suffix) => entry.ref.endsWith(suffix)),
+ ),
+ `${language} index retained an excluded alias fixture`,
+ );
+ assert.ok(entries.every((entry) => entry.boost > 0));
+ assert.ok(entries.every((entry) => Array.isArray(entry.keywords)));
+ assert.ok(entries.every((entry) => Array.isArray(entry.breadcrumb)));
+ t.diagnostic(
+ `${language}: ${entries.length} entries, ${payload.length} B raw, ` +
+ `${compressed.length} B gzip`,
+ );
+ }
+ });
+}
diff --git a/tests/alt-site/prd4-navigation.test.mjs b/tests/alt-site/prd4-navigation.test.mjs
new file mode 100644
index 0000000..3fb1984
--- /dev/null
+++ b/tests/alt-site/prd4-navigation.test.mjs
@@ -0,0 +1,251 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { existsSync, readFileSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { JSDOM } from 'jsdom';
+
+const siteDir = fileURLToPath(new URL('../../', import.meta.url));
+const fixtureDir = join(siteDir, 'tests', 'fixtures', 'prd4-navigation');
+const localWorkspace = join(siteDir, 'go.work');
+const moduleWorkspace =
+ process.env.HUGO_MODULE_WORKSPACE ||
+ (existsSync(localWorkspace) ? localWorkspace : undefined);
+
+function build(name, { baseURL, fixture } = {}) {
+ const outDir = join(siteDir, 'tmp', `prd4-navigation-${name}`);
+ rmSync(outDir, { recursive: true, force: true });
+ const args = [
+ 'run',
+ '_hugo',
+ '--',
+ '-e',
+ 'dev',
+ '-DFE',
+ '--baseURL',
+ baseURL,
+ '--destination',
+ outDir,
+ '--noBuildLock',
+ '--printPathWarnings',
+ ];
+ if (fixture) {
+ args.push('--config', `hugo.yml,${join(fixtureDir, fixture)}`);
+ }
+ const result = spawnSync('npm', args, {
+ cwd: siteDir,
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ...(moduleWorkspace ? { HUGO_MODULE_WORKSPACE: moduleWorkspace } : {}),
+ },
+ });
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
+ assert.equal(result.status, 0, `Build failed:\n${output}`);
+ return { outDir, output };
+}
+
+function documentAt(outDir, path) {
+ const file = join(outDir, path.replace(/^\//, ''), 'index.html');
+ assert.ok(existsSync(file), `Missing rendered route ${path}`);
+ return new JSDOM(readFileSync(file, 'utf8')).window.document;
+}
+
+function navbarEntries(document, region = 'desktop') {
+ return [
+ ...document.querySelectorAll(`[data-td-navbar-region="${region}"]`),
+ ].map((node) => ({
+ label: node.dataset.tdNavbarLabel,
+ href: node.getAttribute('href'),
+ level: Number(node.dataset.tdNavbarLevel),
+ kind: node.dataset.tdNavbarKind,
+ target: node.getAttribute('target') || '',
+ rel: node.getAttribute('rel') || '',
+ description:
+ node.querySelector('.td-navbar-entry__description')?.textContent.trim() ||
+ '',
+ }));
+}
+
+for (const [deployment, baseURL, prefix] of [
+ ['root', 'https://example.test/', ''],
+ ['subpath', 'https://example.test/preview/', '/preview'],
+]) {
+ test(`nested navigation resolves EN/ZH under ${deployment} deployment`, () => {
+ const { outDir, output } = build(deployment, { baseURL });
+ assert.doesNotMatch(output, /supports one interactive child level/);
+
+ for (const route of [
+ '/docs',
+ '/blog',
+ '/project',
+ '/zh/docs',
+ '/zh/blog',
+ '/zh/project',
+ ]) {
+ documentAt(outDir, route);
+ }
+
+ for (const [languagePath, labels] of [
+ ['', ['Docs', 'Tutorials', 'Blog', 'Project', 'Issues']],
+ ['/zh', ['文档', '教程', '博客', '项目', '问题反馈']],
+ ]) {
+ const home = documentAt(outDir, languagePath || '/');
+ const desktop = navbarEntries(home);
+ const mobile = navbarEntries(home, 'mobile');
+ for (const label of labels) {
+ assert.ok(
+ desktop.some((entry) => entry.label === label),
+ label,
+ );
+ assert.ok(
+ mobile.some((entry) => entry.label === label),
+ label,
+ );
+ }
+ assert.equal(home.querySelectorAll('[data-td-navbar-toggle]').length, 2);
+ assert.equal(
+ home.querySelectorAll('[data-td-navbar-accordion-toggle]').length,
+ 2,
+ );
+
+ const controls = [...home.querySelectorAll('[aria-controls]')]
+ .map((button) => button.getAttribute('aria-controls'))
+ .filter((id) => id?.startsWith('td-navbar-'));
+ assert.equal(new Set(controls).size, controls.length);
+ for (const id of controls) {
+ assert.equal(home.querySelectorAll(`#${id}`).length, 1, id);
+ }
+
+ const tutorial = desktop.find((entry) => entry.label === labels[1]);
+ assert.equal(tutorial.level, 1);
+ assert.ok(tutorial.description.length > 10);
+ const external = desktop.find((entry) => entry.label === labels[4]);
+ assert.equal(external.target, '_blank');
+ assert.equal(external.rel, 'noopener noreferrer');
+ assert.equal(external.href, 'https://github.com/pgsty/oink/issues');
+ }
+
+ const englishHome = navbarEntries(documentAt(outDir, '/'));
+ assert.equal(
+ englishHome.find((entry) => entry.label === 'Docs').href,
+ `${prefix}/docs/`,
+ );
+ assert.equal(
+ englishHome.find((entry) => entry.label === 'Tutorials').href,
+ `${prefix}/docs/tutorial/`,
+ );
+ const chineseHome = navbarEntries(documentAt(outDir, '/zh'));
+ assert.equal(
+ chineseHome.find((entry) => entry.label === '文档').href,
+ `${prefix}/zh/docs/`,
+ );
+
+ for (const [route, current, expected] of [
+ ['/docs', 'Docs', ['Docs', 'Blog', 'Project']],
+ ['/project', 'Project', ['Docs', 'Blog', 'Project']],
+ ['/zh/docs', '文档', ['文档', '博客', '项目']],
+ ['/zh/project', '项目', ['文档', '博客', '项目']],
+ ]) {
+ const page = documentAt(outDir, route);
+ assert.equal(
+ page.querySelector('.td-shell-root__title')?.textContent.trim(),
+ current,
+ );
+ const roots = [
+ ...page.querySelectorAll('.td-shell-root__item-title'),
+ ].map((node) => node.textContent.trim());
+ assert.deepEqual(roots, expected);
+ }
+
+ for (const route of [
+ '/docs',
+ '/blog',
+ '/project',
+ '/zh/docs',
+ '/zh/blog',
+ '/zh/project',
+ ]) {
+ assert.equal(
+ documentAt(outDir, route)
+ .querySelector('[data-sidebar-icon-policy]')
+ ?.getAttribute('data-sidebar-icon-policy'),
+ 'groups',
+ `${route} sidebar policy`,
+ );
+ }
+ });
+}
+
+test('flat legacy fixture emits links without disclosure controls', () => {
+ const { outDir, output } = build('flat', {
+ baseURL: 'https://example.test/preview/',
+ fixture: 'flat.yml',
+ });
+ assert.doesNotMatch(output, /supports one interactive child level/);
+ const home = documentAt(outDir, '/');
+ assert.equal(home.querySelectorAll('[data-td-navbar-toggle]').length, 0);
+ assert.equal(
+ home.querySelectorAll('[data-td-navbar-accordion-toggle]').length,
+ 0,
+ );
+ assert.deepEqual(
+ navbarEntries(home).map(({ label, href, level, target, rel }) => ({
+ label,
+ href,
+ level,
+ target,
+ rel,
+ })),
+ [
+ { label: 'Docs', href: '/preview/docs/', level: 0, target: '', rel: '' },
+ { label: 'Blog', href: '/preview/blog/', level: 0, target: '', rel: '' },
+ {
+ label: 'Project',
+ href: '/preview/project/',
+ level: 0,
+ target: '',
+ rel: '',
+ },
+ {
+ label: 'Issues',
+ href: 'https://github.com/pgsty/oink/issues',
+ level: 0,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ ],
+ );
+});
+
+test('deep fixture warns and degrades to a static group', () => {
+ const { outDir, output } = build('deep', {
+ baseURL: 'https://example.test/preview/',
+ fixture: 'deep.yml',
+ });
+ assert.match(output, /supports one interactive child level/);
+ const home = documentAt(outDir, '/');
+ assert.equal(home.querySelectorAll('[data-td-navbar-toggle]').length, 1);
+ assert.equal(
+ home.querySelectorAll('[data-td-navbar-accordion-toggle]').length,
+ 1,
+ );
+ assert.equal(home.querySelectorAll('[data-td-navbar-group]').length, 2);
+
+ const desktop = navbarEntries(home);
+ const tutorial = desktop.find((entry) => entry.label === 'Tutorials');
+ const advanced = desktop.find((entry) => entry.label === 'Advanced setup');
+ assert.deepEqual(
+ { level: tutorial.level, kind: tutorial.kind },
+ { level: 1, kind: 'group' },
+ );
+ assert.deepEqual(
+ { level: advanced.level, kind: advanced.kind },
+ { level: 2, kind: 'link' },
+ );
+ assert.equal(
+ home.querySelectorAll('[data-td-navbar-group] button').length,
+ 0,
+ );
+});
diff --git a/tests/alt-site/prd4-runtime.test.mjs b/tests/alt-site/prd4-runtime.test.mjs
new file mode 100644
index 0000000..86008d2
--- /dev/null
+++ b/tests/alt-site/prd4-runtime.test.mjs
@@ -0,0 +1,177 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { existsSync, readFileSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const siteDir = fileURLToPath(new URL('../../', import.meta.url));
+const localWorkspace = join(siteDir, 'go.work');
+const moduleWorkspace =
+ process.env.HUGO_MODULE_WORKSPACE ||
+ (existsSync(localWorkspace) ? localWorkspace : undefined);
+
+function build(name, extraArgs = []) {
+ const outDir = join(siteDir, 'tmp', `prd4-runtime-${name}`);
+ rmSync(outDir, { recursive: true, force: true });
+ const result = spawnSync(
+ 'npm',
+ [
+ 'run',
+ '_hugo',
+ '--',
+ '-e',
+ 'dev',
+ '-DFE',
+ '--baseURL',
+ 'https://example.test/preview/',
+ '--destination',
+ outDir,
+ '--noBuildLock',
+ ...extraArgs,
+ ],
+ {
+ cwd: siteDir,
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ...(moduleWorkspace ? { HUGO_MODULE_WORKSPACE: moduleWorkspace } : {}),
+ },
+ },
+ );
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
+ assert.equal(result.status, 0, `Build failed:\n${output}`);
+ return outDir;
+}
+
+function html(outDir, relative) {
+ const path = join(outDir, relative);
+ assert.ok(existsSync(path), `Missing ${path}`);
+ return readFileSync(path, 'utf8');
+}
+
+function mainBundle(outDir, pageHTML) {
+ const source = [...pageHTML.matchAll(/