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(/]+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( + /