From 07a50446d951d046baadddd51e2459f634b814fa Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 12 Aug 2026 15:01:52 +0800 Subject: [PATCH 1/6] feat: integrate PRD 4 nested navigation --- content/blog/_index.md | 1 - content/blog/_index.zh.md | 1 - content/docs/_index.md | 1 - content/docs/_index.zh.md | 1 - content/project/_index.md | 7 +- content/project/_index.zh.md | 35 ++++ hugo.yml | 141 ++++++++++++- playwright.config.mjs | 4 +- tests/alt-site/prd4-navigation.test.mjs | 251 ++++++++++++++++++++++++ tests/browser/accessibility.spec.mjs | 29 +++ tests/browser/responsive-shell.spec.mjs | 104 +++++++++- tests/fixtures/prd4-navigation/deep.yml | 57 ++++++ tests/fixtures/prd4-navigation/flat.yml | 45 +++++ 13 files changed, 666 insertions(+), 11 deletions(-) create mode 100644 content/project/_index.zh.md create mode 100644 tests/alt-site/prd4-navigation.test.mjs create mode 100644 tests/fixtures/prd4-navigation/deep.yml create mode 100644 tests/fixtures/prd4-navigation/flat.yml diff --git a/content/blog/_index.md b/content/blog/_index.md index a194eae..4d343b2 100644 --- a/content/blog/_index.md +++ b/content/blog/_index.md @@ -2,7 +2,6 @@ downstream_modified: true title: Blog description: Docsy articles, OINK engineering stories, and OINK release notes -menu: { main: { weight: 50 } } type: blog icon: fa-solid fa-blog sidebar_root_for: self diff --git a/content/blog/_index.zh.md b/content/blog/_index.zh.md index 195c91c..cbe8b46 100644 --- a/content/blog/_index.zh.md +++ b/content/blog/_index.zh.md @@ -1,7 +1,6 @@ --- title: 博客 description: Docsy 文章、OINK 工程实践与 OINK 发布注记 -menu: { main: { weight: 50 } } type: blog icon: fa-solid fa-blog sidebar_root_for: self diff --git a/content/docs/_index.md b/content/docs/_index.md index 899b81c..34e63f2 100644 --- a/content/docs/_index.md +++ b/content/docs/_index.md @@ -3,7 +3,6 @@ downstream_modified: true title: Welcome to OINK linkTitle: Docs description: Install, customize, deploy, and maintain Oink documentation sites. -menu: { main: { weight: 20 } } type: docs icon: fa-solid fa-book sidebar_expanded: true diff --git a/content/docs/_index.zh.md b/content/docs/_index.zh.md index 6f116a5..c9dec17 100644 --- a/content/docs/_index.zh.md +++ b/content/docs/_index.zh.md @@ -2,7 +2,6 @@ title: 欢迎使用 OINK linkTitle: 文档 description: 安装、定制、部署与维护 Oink 文档站。 -menu: { main: { weight: 20 } } type: docs icon: fa-solid fa-book sidebar_expanded: true diff --git a/content/project/_index.md b/content/project/_index.md index ea902ac..313616a 100644 --- a/content/project/_index.md +++ b/content/project/_index.md @@ -1,8 +1,13 @@ --- title: Oink project and website documentation -linkTitle: Project docs +linkTitle: Project description: How the Oink theme and website are built, maintained, and deployed. aliases: [site] +type: docs +icon: fa-solid fa-diagram-project +sidebar_root_for: self +sidebar_root_link_self: true +comments: false cascade: type: docs params: diff --git a/content/project/_index.zh.md b/content/project/_index.zh.md new file mode 100644 index 0000000..7e2897d --- /dev/null +++ b/content/project/_index.zh.md @@ -0,0 +1,35 @@ +--- +title: OINK 项目与站点文档 +linkTitle: 项目 +description: OINK 主题与项目站的构建、维护和部署说明。 +aliases: [site] +type: docs +icon: fa-solid fa-diagram-project +sidebar_root_for: self +sidebar_root_link_self: true +comments: false +cascade: + type: docs + 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/hugo.yml b/hugo.yml index 1042387..9caa4e0 100644 --- a/hugo.yml +++ b/hugo.yml @@ -43,6 +43,74 @@ languages: giscus: ariaLabel: Comments errorMessage: Comments could not be loaded. Please try again later. + 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 @@ -99,6 +167,74 @@ languages: no: >- 很抱歉本页没有解决问题。请告诉我们缺少什么。 + 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 +318,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/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/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/browser/accessibility.spec.mjs b/tests/browser/accessibility.spec.mjs index 5729a46..a3e2f99 100644 --- a/tests/browser/accessibility.spec.mjs +++ b/tests/browser/accessibility.spec.mjs @@ -176,4 +176,33 @@ test.describe('WCAG AA contract', () => { ).toEqual([]); }); } + + for (const { label, viewport, open } of [ + { + label: 'desktop navbar disclosure', + viewport: { width: 1280, height: 900 }, + open: async (page) => { + await page.locator('[data-td-navbar-toggle]').first().click(); + }, + }, + { + label: 'mobile navbar accordion', + viewport: { width: 390, height: 844 }, + open: async (page) => { + await page.locator('[data-menu-toggle]').click(); + await page.locator('[data-td-navbar-accordion-toggle]').first().click(); + }, + }, + ]) { + test(`open ${label}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await open(page); + const { violations } = await scan(page); + expect( + violations, + `${label}\n${describeViolations('/', violations)}`, + ).toEqual([]); + }); + } }); diff --git a/tests/browser/responsive-shell.spec.mjs b/tests/browser/responsive-shell.spec.mjs index dede039..3f00649 100644 --- a/tests/browser/responsive-shell.spec.mjs +++ b/tests/browser/responsive-shell.spec.mjs @@ -62,8 +62,17 @@ for (const width of widths) { }); } -for (const [locale, path, docsLabel, docsHref, blogLabel, blogHref] of [ - ['en', docPath, 'Docs', '/docs/', 'Blog', '/blog/'], +for (const [ + locale, + path, + docsLabel, + docsHref, + blogLabel, + blogHref, + projectLabel, + projectHref, +] of [ + ['en', docPath, 'Docs', '/docs/', 'Blog', '/blog/', 'Project', '/project/'], [ 'zh', '/zh/docs/content/configuration/', @@ -71,9 +80,11 @@ for (const [locale, path, docsLabel, docsHref, blogLabel, blogHref] of [ '/zh/docs/', '博客', '/zh/blog/', + '项目', + '/zh/project/', ], ]) { - test(`${locale} root switcher resolves docs and blog sections`, async ({ + test(`${locale} root switcher resolves docs, blog, and project sections`, async ({ page, }) => { await page.setViewportSize({ width: 820, height: 900 }); @@ -84,7 +95,7 @@ for (const [locale, path, docsLabel, docsHref, blogLabel, blogHref] of [ await trigger.click(); const items = page.locator('.td-shell-root__item'); - await expect(items).toHaveCount(2); + await expect(items).toHaveCount(3); await expect(items.nth(0)).toHaveAttribute('href', docsHref); await expect(items.nth(0).locator('.td-shell-root__item-title')).toHaveText( docsLabel, @@ -93,9 +104,94 @@ for (const [locale, path, docsLabel, docsHref, blogLabel, blogHref] of [ await expect(items.nth(1).locator('.td-shell-root__item-title')).toHaveText( blogLabel, ); + await expect(items.nth(2)).toHaveAttribute('href', projectHref); + await expect(items.nth(2).locator('.td-shell-root__item-title')).toHaveText( + projectLabel, + ); }); } +test('desktop navbar keeps parent navigation separate from disclosure', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await openCleanPage(page, '/'); + + const menu = page.locator('[data-td-navbar-menu]').filter({ + has: page.locator('[data-td-navbar-label="Docs"]'), + }); + const parent = menu.locator('.nav-menu__parent-link'); + const toggle = menu.locator('[data-td-navbar-toggle]'); + const panel = menu.locator('[data-td-navbar-panel]'); + await expect(parent).toHaveAttribute('href', '/docs/'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(panel).toBeHidden(); + + await parent.evaluate((link) => { + link.addEventListener('click', (event) => event.preventDefault(), { + once: true, + }); + link.click(); + }); + await expect(panel).toBeHidden(); + + await toggle.focus(); + await toggle.press('ArrowDown'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(panel).toBeVisible(); + await expect(panel.locator('a').first()).toBeFocused(); + await expect( + panel.locator('.td-navbar-entry__description').first(), + ).toHaveText(/first documentation site/); + + await page.keyboard.press('Escape'); + await expect(panel).toBeHidden(); + await expect(toggle).toBeFocused(); + + const issues = page.locator( + '[data-td-navbar-region="desktop"][data-td-navbar-label="Issues"]', + ); + await expect(issues).toHaveAttribute( + 'href', + 'https://github.com/pgsty/oink/issues', + ); + await expect(issues).toHaveAttribute('target', '_blank'); + await expect(issues).toHaveAttribute('rel', 'noopener noreferrer'); +}); + +test('mobile navbar accordions allow multiple open parents', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openCleanPage(page, '/'); + await page.locator('[data-menu-toggle]').click(); + const drawer = page.locator('[data-mobile-menu]'); + await expect(drawer).toBeVisible(); + + const sections = drawer.locator('[data-td-navbar-accordion]'); + await expect(sections).toHaveCount(2); + const docs = sections.nth(0); + const blog = sections.nth(1); + await expect(docs.locator('.mobile-menu-parent-link')).toHaveAttribute( + 'href', + '/docs/', + ); + + await docs.locator('[data-td-navbar-accordion-toggle]').click(); + await blog.locator('[data-td-navbar-accordion-toggle]').click(); + await expect(docs.locator('[data-td-navbar-accordion-panel]')).toBeVisible(); + await expect(blog.locator('[data-td-navbar-accordion-panel]')).toBeVisible(); + await expect( + docs.locator('[data-td-navbar-label="Tutorials"]'), + ).toContainText('Tutorials'); + + await expect(drawer.locator('[data-td-shell-search-open]')).toBeVisible(); + await expect(drawer.locator('[data-td-theme-toggle]')).toBeVisible(); + await expect( + drawer.locator('.mobile-menu-chip[hreflang="zh-CN"]'), + ).toBeVisible(); +}); + test('page actions are complete and keyboard operable', async ({ page }) => { await page.setViewportSize({ width: 820, height: 900 }); await openCleanPage(page); diff --git a/tests/fixtures/prd4-navigation/deep.yml b/tests/fixtures/prd4-navigation/deep.yml new file mode 100644 index 0000000..473dd9b --- /dev/null +++ b/tests/fixtures/prd4-navigation/deep.yml @@ -0,0 +1,57 @@ +# Non-production degradation fixture: Advanced is deliberately nested below a +# direct child. The theme must warn and flatten it below a static group heading. +languages: + en: + 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: Task-oriented tutorials + - identifier: docs-advanced + parent: docs-tutorial + name: Advanced setup + pageRef: /docs/advanced + weight: 10 + params: + icon: fa-solid fa-gears + description: Deliberate depth-two regression entry + - identifier: project + name: Project + pageRef: /project + weight: 70 + zh: + 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: 任务导向的教程 + - identifier: docs-advanced + parent: docs-tutorial + name: 高级配置 + pageRef: /docs/advanced + weight: 10 + params: + icon: fa-solid fa-gears + description: 用于深度降级回归的二级条目 + - identifier: project + name: 项目 + pageRef: /project + weight: 70 diff --git a/tests/fixtures/prd4-navigation/flat.yml b/tests/fixtures/prd4-navigation/flat.yml new file mode 100644 index 0000000..6042fed --- /dev/null +++ b/tests/fixtures/prd4-navigation/flat.yml @@ -0,0 +1,45 @@ +# Non-production compatibility fixture: the pre-PRD4 flat menu must continue +# to render as ordinary links without disclosure controls. +languages: + en: + menus: + main: + - identifier: docs + name: Docs + pageRef: /docs + weight: 20 + - identifier: blog + name: Blog + pageRef: /blog + weight: 50 + - identifier: project + name: Project + pageRef: /project + weight: 70 + - identifier: issues + name: Issues + url: https://github.com/pgsty/oink/issues + weight: 90 + params: + icon: fa-brands fa-github + zh: + menus: + main: + - identifier: docs + name: 文档 + pageRef: /docs + weight: 20 + - identifier: blog + name: 博客 + pageRef: /blog + weight: 50 + - identifier: project + name: 项目 + pageRef: /project + weight: 70 + - identifier: issues + name: 问题反馈 + url: https://github.com/pgsty/oink/issues + weight: 90 + params: + icon: fa-brands fa-github From 6480e0fbcbd09d00391feb24b46d7c5167a9edcf Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 12 Aug 2026 15:18:25 +0800 Subject: [PATCH 2/6] feat: exercise PRD 4 command palette --- content/blog/_index.md | 2 + content/blog/_index.zh.md | 2 + content/docs/_index.md | 3 + content/docs/_index.zh.md | 3 + content/docs/content/configuration.md | 2 + content/docs/content/configuration.zh.md | 2 + content/project/_index.md | 2 + content/project/_index.zh.md | 2 + content/tests/alerts.md | 1 + content/tests/code-blocks.md | 1 + content/tests/layouts/no-left-sidebar.md | 1 + hugo.yml | 53 ++++ package.json | 3 +- tests/alt-site/offline-search.test.mjs | 198 ++++++++++---- tests/alt-site/prd4-runtime.test.mjs | 177 ++++++++++++ tests/browser/command-palette.spec.mjs | 255 ++++++++++++++++++ .../fixtures/prd4-palette/search-disabled.yml | 2 + 17 files changed, 654 insertions(+), 55 deletions(-) create mode 100644 tests/alt-site/prd4-runtime.test.mjs create mode 100644 tests/browser/command-palette.spec.mjs create mode 100644 tests/fixtures/prd4-palette/search-disabled.yml diff --git a/content/blog/_index.md b/content/blog/_index.md index 4d343b2..6ee33ed 100644 --- a/content/blog/_index.md +++ b/content/blog/_index.md @@ -2,6 +2,7 @@ downstream_modified: true title: Blog description: Docsy articles, OINK engineering stories, and OINK release notes +search_keywords: [oink news, release notes, docsy] type: blog icon: fa-solid fa-blog sidebar_root_for: self @@ -9,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 cbe8b46..aa4072d 100644 --- a/content/blog/_index.zh.md +++ b/content/blog/_index.zh.md @@ -1,6 +1,7 @@ --- title: 博客 description: Docsy 文章、OINK 工程实践与 OINK 发布注记 +search_keywords: [OINK 新闻, 版本说明, Docsy] type: blog icon: fa-solid fa-blog sidebar_root_for: self @@ -8,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 34e63f2..eb25158 100644 --- a/content/docs/_index.md +++ b/content/docs/_index.md @@ -3,12 +3,15 @@ downstream_modified: true title: Welcome to OINK linkTitle: Docs description: Install, customize, deploy, and maintain Oink documentation sites. +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 c9dec17..6592798 100644 --- a/content/docs/_index.zh.md +++ b/content/docs/_index.zh.md @@ -2,12 +2,15 @@ title: 欢迎使用 OINK linkTitle: 文档 description: 安装、定制、部署与维护 Oink 文档站。 +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 313616a..02b1407 100644 --- a/content/project/_index.md +++ b/content/project/_index.md @@ -2,6 +2,7 @@ title: Oink project and website documentation 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 @@ -10,6 +11,7 @@ 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 index 7e2897d..a7efc1e 100644 --- a/content/project/_index.zh.md +++ b/content/project/_index.zh.md @@ -2,6 +2,7 @@ title: OINK 项目与站点文档 linkTitle: 项目 description: OINK 主题与项目站的构建、维护和部署说明。 +search_keywords: [架构, 维护, 部署, 代码仓库] aliases: [site] type: docs icon: fa-solid fa-diagram-project @@ -10,6 +11,7 @@ sidebar_root_link_self: true comments: false cascade: type: docs + search_boost: 1.15 params: hide_feedback: true --- 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/hugo.yml b/hugo.yml index 9caa4e0..de32067 100644 --- a/hugo.yml +++ b/hugo.yml @@ -43,6 +43,33 @@ 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 @@ -160,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: >- 很高兴本页对你有帮助!欢迎 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-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(/]+src="([^"]+)"/g)] + .map((match) => match[1]) + .find((value) => /\/js\/main-[^/]+\.js$/.test(value)); + assert.ok(source, 'Page has no main bundle'); + return html(outDir, source.replace('/preview/', '')); +} + +function manifest(pageHTML) { + const match = pageHTML.match( + /