diff --git a/.github/workflows/check-internal-links.yml b/.github/workflows/check-internal-links.yml index 5b07197f81..b14610ad09 100644 --- a/.github/workflows/check-internal-links.yml +++ b/.github/workflows/check-internal-links.yml @@ -14,18 +14,22 @@ on: - "docs/content/**" - "scripts/sync-hive-docs.ts" - "scripts/check-internal-links.ts" + - "scripts/check-internal-links-helpers.ts" - "src/app/docs/page-map.ts" - "src/app/docs/**" - - "src/config/versions.ts" + - "src/lib/nav.ts" + - "src/config/versions/**" push: branches: [main] paths: - "docs/content/**" - "scripts/sync-hive-docs.ts" - "scripts/check-internal-links.ts" + - "scripts/check-internal-links-helpers.ts" - "src/app/docs/page-map.ts" - "src/app/docs/**" - - "src/config/versions.ts" + - "src/lib/nav.ts" + - "src/config/versions/**" permissions: read-all diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc326e2403..92fe3494e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,8 @@ also changes product code outside this repository. ## What lives in this repository - `docs/content/` — documentation source files (`.md` and `.mdx`) -- `src/app/docs/page-map.ts` — sidebar and navigation structure +- `docs/content//nav.yaml` — sidebar navigation for each project (and + `docs/content/{contributing,community,news}/nav.yaml` for the shared sections) - `src/` — site code, theme customizations, and shared components - `public/` — static assets used by the docs site @@ -37,7 +38,7 @@ Use a local clone when you need to: - update multiple pages at once - add images or other assets -- change navigation in `src/app/docs/page-map.ts` +- change navigation in a `nav.yaml` file under `docs/content/` - edit components, styling, or site behavior under `src/` - validate a more complex docs change before opening a PR @@ -89,9 +90,24 @@ Most documentation edits happen in `docs/content/`. ### Navigation changes -The docs navigation is not generated automatically from the filesystem. -When you add, remove, rename, or move a page in the site navigation, also update -`src/app/docs/page-map.ts`. +The docs navigation is not generated automatically from the filesystem. Each +project's sidebar is defined in a `nav.yaml` next to its content — +`docs/content/nav.yaml` for KubeStellar, `docs/content//nav.yaml` for +every other project — and the shared Contributing / Community / News sections +live in `docs/content/
/nav.yaml`. When you add, remove, rename, or move +a page in the site navigation, also update the matching `nav.yaml`: + +```yaml +- title: Getting Started + items: + - Quick Start: getting-started/quick-start.md + - Advanced: + - Custom Setup: getting-started/advanced/custom-setup.md +``` + +Paths are relative to the directory that holds the `nav.yaml`. A page that is +not listed does not appear in the sidebar; a malformed `nav.yaml` fails +`npm run build` (and `npm test`) with the file and location of the problem. ### Version-aware changes @@ -211,7 +227,7 @@ When reviewing a PR, pay attention to: 1. **Content Quality** - Clarity and accuracy of documentation changes - Correct Markdown/MDX syntax and formatting - - Proper navigation structure updates in `src/app/docs/page-map.ts` + - Proper navigation structure updates in the relevant `docs/content/**/nav.yaml` - Working links and valid examples 2. **Security-Sensitive Changes** @@ -390,7 +406,7 @@ For safety reasons, copies of the docs source may remain in a to-be-deleted fold | ├📁 src/ ← Source for NEW pages, site nav and layout | | | ├📁 app/ | | | | ├📁 docs/ ← layouts to apply to component docs pages | -| | | ├── 📄page-map.ts ← Defines navigation structure │ +| | | ├── 📄page-map.ts ← Builds sidebar from nav.yaml │ │ | | ├── 📄layout.tsx ← Nextra theme integration │ | | | └── 📄page.mdx ← Nextra page master │ | | ├📁 components/ │ @@ -418,7 +434,7 @@ For safety reasons, copies of the docs source may remain in a to-be-deleted fold - ✅ **Content lives in the docs/content folder of this kubestellar/docs repo** (`docs/content/`) - ✅ **The website structure is defined in the src folder of this repo** - ✅ **This repo also contains the website framework** (Next.js + Nextra) -- ✅ **Navigation is defined in `page-map.ts`** (not auto-generated from files) +- ✅ **Navigation is defined in `nav.yaml` files under `docs/content/`** (not auto-generated from files) ### How Nextra Integration Works @@ -447,13 +463,13 @@ This documentation site is built using **Nextra**, a powerful Next.js-based docu - Enables dark mode and collapsible sidebar sections 3. **`src/app/docs/page-map.ts`** - Navigation structure builder that: - - Defines the documentation navigation structure in `NAV_STRUCTURE` + - Loads each project's navigation from its `nav.yaml` (`PROJECTS[project].navPath` in `src/config/versions/lookup.ts`) plus the shared `contributing`, `community` and `news` sections, validating the schema at build time (`src/lib/nav.ts`) - Reads documentation files from the local `/docs/content/` directory - Constructs hierarchical navigation from the defined structure - Generates routes for each documentation page - Creates a mapping between file paths and URL routes - **Note:** The file tree structure in _/docs/content_ roughly parallels the navigation created in _pagemap.ts_ but is **not** identical. As the new site matures many of the differences will be smoothed out - - Using the page-map rather than file structure to generate the `NAV_STRUCTURE` simplifies changing menus for different locales (languages) + - Using explicit `nav.yaml` files rather than the file structure to generate the navigation simplifies changing menus for different locales (languages) 4. **`src/app/docs/[...slug]/page.tsx`** - Dynamic page renderer that: - Reads MDX content from the local `/docs/content/` directory @@ -510,7 +526,15 @@ All documentation content lives in this repository: #### Navigation Structure -The navigation is defined in `src/app/docs/page-map.ts` in the `NAV_STRUCTURE` constant. This defines how documentation pages are organized and displayed in the sidebar. +The navigation is defined in `nav.yaml` data files that live next to the content they describe: + +| File | Sidebar it defines | +| ------------------------------------------------------ | ---------------------------------------------------- | +| `docs/content/nav.yaml` | KubeStellar (paths relative to `docs/content/`) | +| `docs/content//nav.yaml` | Each other project, e.g. `console`, `kubeflex` | +| `docs/content/{contributing,community,news}/nav.yaml` | Shared sections appended to every project's sidebar | + +Each file is a list of sections; each section has a `title` and a list of `items`. An item is either `Page Title: relative/path.md` or `Folder Title:` followed by a nested list. `src/app/docs/page-map.ts` loads these files (via `PROJECTS[project].navPath`, see `src/config/versions/lookup.ts`) and validates them at build time — a missing or malformed `nav.yaml` fails `npm run build` and `npm test` with the file and location of the problem. #### Adding New Content @@ -523,15 +547,15 @@ To add new documentation pages: - You can use template variables: `{{ config.variable_name }}` 2. **Update the Navigation:** - - Edit `src/app/docs/page-map.ts` - - Find the appropriate section in `NAV_STRUCTURE` + - Edit the `nav.yaml` for the project (`docs/content/nav.yaml` for KubeStellar, `docs/content//nav.yaml` otherwise) + - Find the appropriate section - Add an entry for your new file: - ```typescript - { 'Page Title': 'path/to/your-file.md' } + ```yaml + - Page Title: path/to/your-file.md ``` - - The file path is relative to `/docs/content/` + - The file path is relative to the directory that holds the `nav.yaml` 3. **Preview Your Changes:** @@ -543,34 +567,25 @@ To add new documentation pages: #### Example: Adding a New Getting Started Guide -```typescript -// In src/app/docs/page-map.ts, within NAV_STRUCTURE -{ - title: 'User Guide', - items: [ - { 'Quick Start': 'kubestellar/get-started.md' }, - { 'Your New Guide': 'kubestellar/new-guide.md' }, // Add this line - // ... rest of the entries - ] -} +```yaml +# In docs/content/nav.yaml +- title: User Guide + items: + - Quick Start: kubestellar/get-started.md + - Your New Guide: kubestellar/new-guide.md # Add this line + # ... rest of the entries ``` #### Adding Nested Sections For hierarchical navigation: -```typescript -{ - 'Parent Section': [ - { 'Subsection 1': 'path/to/file1.md' }, - { 'Subsection 2': 'path/to/file2.md' }, - { - 'Nested Section': [ - { 'Deep Page': 'path/to/deep/file.md' } - ] - } - ] -} +```yaml +- Parent Section: + - Subsection 1: path/to/file1.md + - Subsection 2: path/to/file2.md + - Nested Section: + - Deep Page: path/to/deep/file.md ``` @@ -578,8 +593,8 @@ For hierarchical navigation: You can also add external documentation links: -```typescript -{ 'API Reference (new tab)': 'https://pkg.go.dev/github.com/kubestellar/kubestellar/api/control/v1alpha1' } +```yaml +- API Reference (new tab): https://pkg.go.dev/github.com/kubestellar/kubestellar/api/control/v1alpha1 ``` ### Version Management @@ -627,7 +642,7 @@ The site when first loaded shows the **latest** tagged version of the KubeStella 3. **Content Verification:** - Ensure the content file exists in the docs repository - - Verify the file path in `page-map.ts` matches exactly + - Verify the file path in the project's `nav.yaml` matches exactly - Check that the category structure makes logical sense ### Common Issues @@ -639,8 +654,8 @@ The site when first loaded shows the **latest** tagged version of the KubeStella - Rebuild the page map 2. **Navigation Issues:** - - Check `NAV_STRUCTURE` structure syntax - - Ensure proper nesting of arrays and objects + - Check the `nav.yaml` syntax — the build error names the file, section and item + - Ensure proper indentation of nested lists - Verify route generation logic 3. **Content Not Updating:** @@ -682,16 +697,16 @@ echo "# My New Page" > docs/content/my-new-page.md git add docs/content/my-new-page.md git commit -m "Add new documentation page" -# Step 2: Update navigation in page-map.ts -# Edit src/app/docs/page-map.ts to add your page -# Add: { file: 'my-new-page.md' } in appropriate category +# Step 2: Update navigation in nav.yaml +# Edit docs/content/nav.yaml to add your page +# Add: "- My New Page: my-new-page.md" under the appropriate section # Step 3: Test locally npm run dev # Visit http://localhost:3000/docs to verify # Step 4: Commit and push -git add src/app/docs/page-map.ts +git add docs/content/nav.yaml git commit -m "Add my-new-page to navigation" git push ``` @@ -699,9 +714,8 @@ git push #### Workflow 2: Reorganizing Navigation ```sh -# Edit src/app/docs/page-map.ts -# Modify NAV_STRUCTURE array -# Example: Move a page to different category +# Edit the project's docs/content/**/nav.yaml +# Example: Move a page to a different section npm run dev # Test changes npm run build # Verify build succeeds git commit -am "Reorganize documentation navigation" @@ -754,7 +768,8 @@ GITHUB_PAT=ghp_your_token_here # Alternative name | File | Purpose | When to Edit | | -------------------------- | ----------------------- | -------------------------------------- | -| `src/app/docs/page-map.ts` | Navigation structure | Adding/removing/reorganizing pages | +| `docs/content/**/nav.yaml` | Navigation structure | Adding/removing/reorganizing pages | +| `src/app/docs/page-map.ts` | Sidebar/route builder | Changing how nav.yaml becomes routes | | `next.config.ts` | Nextra & Next.js config | Changing Nextra settings, redirects | | `src/app/docs/layout.tsx` | Docs page layout | Modifying sidebar, theme, or layout | | `mdx-components.js` | MDX component mappings | Adding custom React components to MDX | @@ -772,8 +787,8 @@ DOCS_FILE_PATH="docs/content/your-file.md" GITHUB_CONTENTS_API="https://api.github.com/repos/kubestellar/docs/contents" curl "${GITHUB_CONTENTS_API}/${DOCS_FILE_PATH}" -# Verify page-map.ts entry -grep -r "your-file.md" src/app/docs/page-map.ts +# Verify nav.yaml entry +grep -r "your-file.md" docs/content --include=nav.yaml # Clear Next.js cache npm run clean diff --git a/docs/content/a2a/nav.yaml b/docs/content/a2a/nav.yaml new file mode 100644 index 0000000000..05fc2470d7 --- /dev/null +++ b/docs/content/a2a/nav.yaml @@ -0,0 +1,25 @@ +# Sidebar navigation for the a2a project (PROJECTS['a2a'].navPath). +# Paths are relative to docs/content/a2a/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Overview + items: + - Introduction: intro.md +- title: Getting Started + items: + - Quick Start: getting-started/quick-start.md + - Installation: getting-started/installation.md + - Guide Overview: getting-started/index.md +- title: Reference + items: + - CLI Reference: cli-reference.md + - Troubleshooting: troubleshooting.md +- title: Contribute to A2A + items: + - Contribute to A2A: CONTRIBUTING.md diff --git a/docs/content/community/nav.yaml b/docs/content/community/nav.yaml new file mode 100644 index 0000000000..6c710620aa --- /dev/null +++ b/docs/content/community/nav.yaml @@ -0,0 +1,15 @@ +# Cross-project 'Community' sidebar section, appended to every project's nav +# (see GENERAL_SECTIONS in src/app/docs/page-map.ts). Paths are relative to docs/content/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Community + items: + - Get Involved: community/index.md + - Videos and Demos: community/videos.md + - Community Meetings: community/meetings.md diff --git a/docs/content/console/nav.yaml b/docs/content/console/nav.yaml new file mode 100644 index 0000000000..92b1699049 --- /dev/null +++ b/docs/content/console/nav.yaml @@ -0,0 +1,72 @@ +# Sidebar navigation for the console project (PROJECTS['console'].navPath). +# Paths are relative to docs/content/console/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Overview + items: + - Introduction: readme.md + - Quick Start: quickstart.md + - Console Overview: console-overview.md + - Architecture: architecture.md + - Architecture Diagram: _architecture-diagram.md + - Installation: installation.md + - Cluster Registration: cluster-registration.md + - Demo Mode: demo-mode.md + - Configuration: configuration.md + - Updates and Releases: console-updates.md +- title: Setup + items: + - Local Setup Guide: local-setup.md + - Local Deployment: local-deployment.md + - Deploy & Orchestrate: deploy.md + - vCluster Setup: vcluster-setup.md +- title: Features + items: + - Console Features: console-features.md + - Dashboards: dashboards.md + - Cards: all-cards.md + - Card Types: cards.md + - Card Reference: console-cards.md + - Stats Blocks: stats-blocks.md + - Alerts: alerts.md + - Cost Optimization Cards: cost-optimization.md + - Drasi Reactive Pipeline Dashboard: drasi-dashboard.md + - KServe Monitoring Card: kserve-monitoring.md + - Feedback System: feedback.md +- title: AI Integration + items: + - AI Features: ai-features.md + - AI Missions Setup: ai-missions-setup.md + - Kagenti LLM Provider Setup: kagenti-llm-provider-setup.md + - Local LLM Strategy: local-llm-strategy.md + - Agentic Quality Controls: agentic-quality.md +- title: Security + items: + - Authentication & Sessions: authentication.md + - Security Model: security-model.md + - Persistence & State Management: persistence.md +- title: Enterprise + items: + - Enterprise Compliance Portal: enterprise-compliance.md + - Federation & Multi-Hub: federation.md +- title: Development + items: + - Development Methodology: development.md +- title: ACMM + items: + - ACMM Dashboard: acmm-dashboard.md +- title: Programs + items: + - Marketplace: marketplace.md + - Knowledge Base: knowledge-base.md + - Rewards System: console-rewards.md +- title: Troubleshooting + items: + - Troubleshooting: troubleshooting.md + - Windows 11 Local Source Build Troubleshooting: windows-11-local-source-build-troubleshooting.md diff --git a/docs/content/contributing/documentation/docs-structure-inc.md b/docs/content/contributing/documentation/docs-structure-inc.md index 1df1f90fc1..13ef99fce4 100644 --- a/docs/content/contributing/documentation/docs-structure-inc.md +++ b/docs/content/contributing/documentation/docs-structure-inc.md @@ -48,7 +48,7 @@ For safety reasons, copies of the docs source may remain in a to-be-deleted fold | ├ src/ <-- Source for pages, site nav and layout | | | ├ app/ | | | | ├ docs/ <-- layouts to apply to component docs pages | -| | | ├── page-map.ts <-- Defines navigation structure │ +| | | ├── page-map.ts <-- Builds sidebar from nav.yaml │ │ | | ├── layout.tsx <-- Nextra theme integration │ | | | └── page.mdx <-- Nextra page master │ | | ├ components/ │ @@ -76,7 +76,7 @@ For safety reasons, copies of the docs source may remain in a to-be-deleted fold - **Content lives in the docs/content folder of this kubestellar/docs repo** (`docs/content/`) - **The website structure is defined in the src folder of this repo** - **This repo also contains the website framework** (Next.js + Nextra) -- **Navigation is defined in `page-map.ts`** (not auto-generated from files) +- **Navigation is defined in `nav.yaml` files under `docs/content/`** (not auto-generated from files) — `docs/content/nav.yaml` for KubeStellar, `docs/content//nav.yaml` for each other project, and `docs/content/{contributing,community,news}/nav.yaml` for the shared sections ### How Nextra Integration Works @@ -105,13 +105,13 @@ This documentation site is built using **Nextra**, a powerful Next.js-based docu - Enables dark mode and collapsible sidebar sections 3. **`src/app/docs/page-map.ts`** - Navigation structure builder that: - - Defines the documentation navigation structure in `NAV_STRUCTURE` + - Loads each project's navigation from its `nav.yaml` (`PROJECTS[project].navPath`, see `src/config/versions/lookup.ts`) plus the shared `contributing`, `community` and `news` sections, validating the schema at build time (`src/lib/nav.ts`) - Reads documentation files from the local `/docs/content/` directory - Constructs hierarchical navigation from the defined structure - Generates routes for each documentation page - Creates a mapping between file paths and URL routes - **Note:** The file tree structure in _/docs/content_ roughly parallels the navigation created in _pagemap.ts_ but is **not** identical. As the new site matures many of the differences will be smoothed out - - Using the page-map rather than file structure to generate the `NAV_STRUCTURE` simplifies changing menus for different locales (languages) + - Using explicit `nav.yaml` files rather than the file structure to generate the navigation simplifies changing menus for different locales (languages) 4. **`src/app/docs/[...slug]/page.tsx`** - Dynamic page renderer that: - Reads MDX content from the local `/docs/content/` directory diff --git a/docs/content/contributing/nav.yaml b/docs/content/contributing/nav.yaml new file mode 100644 index 0000000000..235a8389db --- /dev/null +++ b/docs/content/contributing/nav.yaml @@ -0,0 +1,36 @@ +# Cross-project 'Contributing' sidebar section, appended to every project's nav +# (see GENERAL_SECTIONS in src/app/docs/page-map.ts). Paths are relative to docs/content/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Contributing + items: + - Overview: contributing/contribute.md + - Code of Conduct: contributing/coc-inc.md + - Contributing to Code: contributing/CONTRIBUTINGKS.md + - Contributing to Docs/Website: + - Docs Structure: contributing/documentation/docs-structure-inc.md + - Simple Changes: contributing/documentation/simple-docs-inc.md + - Version Management: contributing/documentation/docs-version-inc.md + - Detailed Contribution Guide: contributing/documentation/contributing-inc.md + - Style Guide: contributing/documentation/docs-styleguide.md + - Contributor Ladder: contributing/contributor_ladder.md + - License: contributing/license-inc.md + - Governance: contributing/governance-inc.md + - Onboarding: contributing/onboarding-inc.md + - CI/CD: + - GitHub Actions: contributing/operations/github-actions.md + - Demoting Component Repo Docs: contributing/operations/demote-component-docs.md + - Security: + - Policy: contributing/security/security-inc.md + - Contacts: contributing/security/security_contacts-inc.md + - Testing: kubestellar/testing.md + - Packaging: kubestellar/packaging.md + - Release Process: kubestellar/release.md + - Release Testing: kubestellar/release-testing.md + - Sign-off: kubestellar/pr-signoff.md diff --git a/docs/content/kubeflex/nav.yaml b/docs/content/kubeflex/nav.yaml new file mode 100644 index 0000000000..287ea6be39 --- /dev/null +++ b/docs/content/kubeflex/nav.yaml @@ -0,0 +1,27 @@ +# Sidebar navigation for the kubeflex project (PROJECTS['kubeflex'].navPath). +# Paths are relative to docs/content/kubeflex/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Overview + items: + - Introduction: readme.md + - Architecture: architecture.md + - Multi-Tenancy: multi-tenancy.md +- title: Getting Started + items: + - Quick Start: quickstart.md + - User Guide: users.md +- title: Development + items: + - Debugging: debugging.md + - Code Generation: code-generation.md + - PostgreSQL Architecture: postgresql-architecture-decision.md +- title: Community + items: + - Contributors: contributors.md diff --git a/docs/content/kubestellar-mcp/nav.yaml b/docs/content/kubestellar-mcp/nav.yaml new file mode 100644 index 0000000000..f9a53a74ab --- /dev/null +++ b/docs/content/kubestellar-mcp/nav.yaml @@ -0,0 +1,36 @@ +# Sidebar navigation for the kubestellar-mcp project (PROJECTS['kubestellar-mcp'].navPath). +# Paths are relative to docs/content/kubestellar-mcp/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Overview + items: + - Introduction: overview/intro.md +- title: Setup + items: + - Homebrew Installation: setup/homebrew.md +- title: Architecture + items: + - Architecture Overview: architecture/overview.md + - Local Development: architecture/local-development.md + - Request and Response Lifecycle: architecture/request-response-lifecycle.md + - Adding a New Tool: architecture/adding-a-tool.md + - Testing: architecture/testing.md +- title: Commands + items: + - App Logs: commands/app-logs.md + - App Status: commands/app-status.md + - Delete: commands/delete.md + - Deploy: commands/deploy.md + - GitOps Drift: commands/gitops-drift.md + - GitOps Sync: commands/gitops-sync.md + - Helm Install: commands/helm-install.md + - Helm Rollback: commands/helm-rollback.md + - Helm Uninstall: commands/helm-uninstall.md + - Kustomize: commands/kustomize.md + - Label: commands/label.md diff --git a/docs/content/multi-plugin/nav.yaml b/docs/content/multi-plugin/nav.yaml new file mode 100644 index 0000000000..fabdd3de58 --- /dev/null +++ b/docs/content/multi-plugin/nav.yaml @@ -0,0 +1,25 @@ +# Sidebar navigation for the multi-plugin project (PROJECTS['multi-plugin'].navPath). +# Paths are relative to docs/content/multi-plugin/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Overview + items: + - Introduction: readme.md + - Architecture: architecture_guide.md +- title: Getting Started + items: + - Installation: installation_guide.md + - Installation (Windows): installation_guide_windows.md + - Usage Guide: usage_guide.md +- title: Reference + items: + - API Reference: api_reference.md +- title: Development + items: + - Development Guide: development_guide.md diff --git a/docs/content/nav.yaml b/docs/content/nav.yaml new file mode 100644 index 0000000000..e082dd9a2b --- /dev/null +++ b/docs/content/nav.yaml @@ -0,0 +1,69 @@ +# Sidebar navigation for the KubeStellar project (PROJECTS.kubestellar.navPath). +# Paths are relative to docs/content/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: Overview + items: + - Introduction: readme.md + - Architecture: kubestellar/architecture.md + - OCM Status Addon: kubestellar/ocm-status-addon-intro.md + - Release Notes: kubestellar/release-notes.md + - Roadmap: kubestellar/roadmap.md +- title: Getting Started + items: + - Quick Start: kubestellar/get-started.md + - Console Quick Start: console/quickstart.md +- title: User Guide + items: + - Guide Overview: kubestellar/user-guide-intro.md + - Observability: kubestellar/observability.md + - Getting Started from OCM: kubestellar/start-from-ocm.md + - General Setup: + - Overview: kubestellar/setup-overview.md + - Setup Limitations: kubestellar/setup-limitations.md + - Prerequisites: kubestellar/pre-reqs.md + - KubeFlex Hosting Cluster: + - Acquire Cluster for KubeFlex Hosting: kubestellar/acquire-hosting-cluster.md + - Initialize KubeFlex Hosting Cluster: kubestellar/init-hosting-cluster.md + - Core Spaces: + - Inventory and Transport Spaces: kubestellar/its.md + - Workload Description Spaces: kubestellar/wds.md + - Core Helm Chart: kubestellar/core-chart.md + - Argo CD Integration: kubestellar/core-chart-argocd.md + - Workload Execution Clusters: + - About Workload Execution Clusters: kubestellar/wec.md + - Register a Workload Execution Cluster: kubestellar/wec-registration.md + - Usage: + - Usage Limitations: kubestellar/usage-limitations.md + - KubeStellar API: + - Overview: kubestellar/control.md + - Binding: kubestellar/binding.md + - Transforming Desired State: kubestellar/transforming.md + - Combining Reported State: kubestellar/combined-status.md + - Multi-WEC Aggregated Status: kubestellar/multi-wec-aggregated-status.md + - Authorization in WECs: kubestellar/authorization.md + - Example Scenarios: kubestellar/example-scenarios.md + - Deploy Helm Charts Through a WDS: kubestellar/helm-through-wds.md + - Third-party Integrations: + - ArgoCD to WDS: kubestellar/argo-to-wds1.md + - Claude Code: kubestellar/claude-code.md + - Troubleshooting: kubestellar/troubleshooting.md + - Known Issues: + - Overview: kubestellar/known-issues.md + - Hidden State in Kubeconfig: kubestellar/knownissue-kflex-extension.md + - Kind Needs OS Reconfig: kubestellar/knownissue-kind-config.md + - Helm Chart Auth Failure: kubestellar/knownissue-helm-ghcr.md + - Missing CombinedStatus Results: kubestellar/knownissue-collector-miss.md + - Kind Host Configuration: kubestellar/installation-errors.md + - Insufficient CPU: kubestellar/knownissue-cpu-insufficient-for-its1.md + - UI (Deprecated → Console): + - Overview: ui-docs/ui-overview.md + - WECS Remote Monitoring: ui-docs/wecs-remote-monitoring.md + - ITS cluster management: ui-docs/its-cluster-management.md + - Teardown: kubestellar/teardown.md diff --git a/docs/content/news/nav.yaml b/docs/content/news/nav.yaml new file mode 100644 index 0000000000..b89a350731 --- /dev/null +++ b/docs/content/news/nav.yaml @@ -0,0 +1,16 @@ +# Cross-project 'News' sidebar section, appended to every project's nav +# (see GENERAL_SECTIONS in src/app/docs/page-map.ts). Paths are relative to docs/content/. +# Each top-level entry is a sidebar section: { title, items }. Items are +# - Title: relative/path.md (page) +# - Title: (folder) +# - Child Title: relative/path.md +# - https://example.com or /abs/path (external / absolute link, as a value) +# A page that is not listed here does not appear in the sidebar. +# Schema is validated at build time by loadNavFile() in src/app/docs/page-map.ts. +# See kubestellar/docs#7080. +- title: News + items: + - Latest News: news/index.md + - Marketplace & KB Launch: news/marketplace-and-kb-launch.md + - KubeStellar Console Announcement: news/kubestellar-console-announcement.md + - Reviews and Testimonials: news/reviews.md diff --git a/package-lock.json b/package-lock.json index 600a0f3a54..ca438fb19e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@theguild/remark-mermaid": "^0.3.0", "dotenv": "^18.0.0", "framer-motion": "^13.2.0", + "js-yaml": "^5.2.2", "lucide-react": "^1.47.0", "mermaid": "^12.0.0", "next": "^16.3.5", @@ -1262,6 +1263,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/js": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", @@ -2556,9 +2580,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2575,9 +2596,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2594,9 +2612,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2613,9 +2628,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3243,9 +3255,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3263,9 +3272,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3283,9 +3289,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3303,9 +3306,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3323,9 +3323,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3343,9 +3340,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5548,7 +5542,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -9788,7 +9781,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", - "dev": true, "funding": [ { "type": "github", @@ -15409,9 +15401,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15433,9 +15422,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15457,9 +15443,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15481,9 +15464,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 52ad5ca9b2..e317e48465 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@theguild/remark-mermaid": "^0.3.0", "dotenv": "^18.0.0", "framer-motion": "^13.2.0", + "js-yaml": "^5.2.2", "lucide-react": "^1.47.0", "mermaid": "^12.0.0", "next": "^16.3.5", @@ -73,7 +74,6 @@ "dompurify": "^3.4.13", "linkify-it": "^6.1.0", "follow-redirects": "^1.16.0", - "js-yaml": "^5.2.2", "markdown-it": "^14.3.0", "nanoid": "^6.0.1", "postcss": "^8.5.24", diff --git a/scripts/check-internal-links-helpers.test.ts b/scripts/check-internal-links-helpers.test.ts index e3cdb463c6..afeca2c48d 100644 --- a/scripts/check-internal-links-helpers.test.ts +++ b/scripts/check-internal-links-helpers.test.ts @@ -1,21 +1,24 @@ import { describe, expect, it } from "vitest"; +import { existsSync } from "node:fs"; import { ASSET_EXT, - PROJECT_FOR_NAV, isExternalOrAnchor, + navAliasEntries, navEntryRoute, + navSourcesFor, normRoute, - parseNavStructures, + parseNavStructure, resolveInternalLink, slugify, type NavAliasEntry, } from "./check-internal-links-helpers"; +import { PROJECTS } from "../src/config/versions/lookup"; +import { GENERAL_SECTIONS } from "../src/lib/nav"; /** * Coverage for scripts/check-internal-links-helpers.ts. * - * The helpers module is currently at 0% coverage even though it hosts the - * regex-heavy parsing logic and link-resolution rules that the internal- + * The helpers module hosts the nav.yaml → route derivation and link-resolution rules that the internal- * link CI check relies on. Any regression here silently makes the checker * either permissive (invents nav routes that don't exist) or over-strict * (misses real nav routes, reports valid links as broken). @@ -119,135 +122,152 @@ describe("ASSET_EXT", () => { }); }); -describe("PROJECT_FOR_NAV", () => { - it("maps the default KUBESTELLAR nav to bare 'docs' (no project subpath)", () => { - expect(PROJECT_FOR_NAV.KUBESTELLAR).toBe("docs"); +// ───────────────────────────────────────────────────────────────────────── +// navSourcesFor — derives the nav.yaml list from PROJECTS + GENERAL_SECTIONS +// (kubestellar/docs#7080). A regression here silently drops a project's nav +// aliases from the route table and mass-reports its links as broken. +// ───────────────────────────────────────────────────────────────────────── +describe("navSourcesFor", () => { + const projects = { + kubestellar: { id: "kubestellar", basePath: "", navPath: "docs/content/nav.yaml" }, + console: { id: "console", basePath: "console", navPath: "docs/content/console/nav.yaml" }, + }; + + it("maps the root project (empty basePath) to bare 'docs'", () => { + const [ks] = navSourcesFor(projects, []); + expect(ks).toEqual({ name: "kubestellar", base: "docs", navPath: "docs/content/nav.yaml" }); }); - it("maps each project nav to its base path", () => { - expect(PROJECT_FOR_NAV.HIVE).toBe("docs/hive"); - expect(PROJECT_FOR_NAV.CONSOLE).toBe("docs/console"); - expect(PROJECT_FOR_NAV.KUBEFLEX).toBe("docs/kubeflex"); - expect(PROJECT_FOR_NAV.KUBESTELLAR_MCP).toBe("docs/kubestellar-mcp"); - expect(PROJECT_FOR_NAV.A2A).toBe("docs/a2a"); - expect(PROJECT_FOR_NAV.MULTI_PLUGIN).toBe("docs/multi-plugin"); + it("maps every other project to docs/ and its own navPath", () => { + const sources = navSourcesFor(projects, []); + expect(sources[1]).toEqual({ name: "console", base: "docs/console", navPath: "docs/content/console/nav.yaml" }); + }); + + it("appends one 'docs'-based source per general section, under /
/nav.yaml", () => { + const sources = navSourcesFor(projects, ["contributing", "news"]); + expect(sources.slice(2)).toEqual([ + { name: "contributing", base: "docs", navPath: "docs/content/contributing/nav.yaml" }, + { name: "news", base: "docs", navPath: "docs/content/news/nav.yaml" }, + ]); + }); + + it("covers every real project and general section with an existing nav.yaml", () => { + for (const s of navSourcesFor(PROJECTS, GENERAL_SECTIONS)) { + expect(existsSync(s.navPath), `${s.name}: ${s.navPath}`).toBe(true); + } }); }); -describe("parseNavStructures", () => { - it("returns [] when the source has no NAV_STRUCTURE_ blocks", () => { - expect(parseNavStructures("const foo = 1")).toEqual([]); - }); - - it("skips NAV_STRUCTURE_ blocks whose suffix is not in PROJECT_FOR_NAV", () => { - const src = ` - const NAV_STRUCTURE_UNKNOWN: X = [ - { title: 'Overview', items: [{ 'Hello': 'hello.md' }] }, -] - `; - expect(parseNavStructures(src)).toEqual([]); - }); - - it("extracts entries and emits one row per section plus one bare row", () => { - const src = ` - const NAV_STRUCTURE_HIVE: X = [ - { title: 'Overview', items: [ - { 'Getting Started': 'getting-started.md' }, - ] }, -] - `; - const entries = parseNavStructures(src); - // One per section + one bare = 2 rows - expect(entries).toHaveLength(2); - const sectionRow = entries.find((e) => e.sectionSlug === "overview"); - const bareRow = entries.find((e) => e.sectionSlug === ""); - expect(sectionRow).toEqual({ - navName: "HIVE", - base: "docs/hive", - sectionSlug: "overview", - slug: "getting-started", - title: "Getting Started", - file: "getting-started.md", - }); - expect(bareRow).toEqual({ - navName: "HIVE", - base: "docs/hive", - sectionSlug: "", - slug: "getting-started", - title: "Getting Started", - file: "getting-started.md", - }); - }); - - it("accepts both single- and double-quoted title/file pairs", () => { - const src = ` - const NAV_STRUCTURE_HIVE: X = [ - { title: "Ops", items: [ - { "Deploy": "deploy.md" }, - { 'Rollback': 'rollback.md' }, - ] }, -] - `; - const slugs = parseNavStructures(src) - .map((e) => e.slug) - .sort(); - expect(slugs).toEqual(["deploy", "deploy", "rollback", "rollback"]); - }); - - it("skips entries whose file value is an http URL or an absolute path", () => { - const src = ` - const NAV_STRUCTURE_HIVE: X = [ - { title: 'Links', items: [ - { 'External': 'https://example.com/x.md' }, - { 'Absolute': '/other/place.md' }, - { 'Real': 'real.md' }, - ] }, -] - `; - const files = parseNavStructures(src).map((e) => e.file); - expect(files).not.toContain("https://example.com/x.md"); - expect(files).not.toContain("/other/place.md"); - expect(files.filter((f) => f === "real.md")).toHaveLength(2); // section + bare - }); - - it("supports .mdx entries", () => { - const src = ` - const NAV_STRUCTURE_HIVE: X = [ - { title: 'Guides', items: [ - { 'Tutorial': 'tutorial.mdx' }, - ] }, -] - `; - const entries = parseNavStructures(src); - expect(entries.length).toBeGreaterThan(0); - expect(entries[0].file).toBe("tutorial.mdx"); - }); - - it("emits an entry per section for blocks with multiple sections", () => { - const src = ` - const NAV_STRUCTURE_HIVE: X = [ - { title: 'Overview', items: [] }, - { title: 'Ops', items: [ - { 'Deploy': 'deploy.md' }, - ] }, -] - `; - const entries = parseNavStructures(src); - const sections = entries.map((e) => e.sectionSlug).sort(); - // 2 sections + 1 bare = 3 rows for the single 'deploy' entry - expect(sections).toEqual(["", "ops", "overview"]); - }); - - it("honors a custom projectForNav override", () => { - const src = ` - const NAV_STRUCTURE_HIVE: X = [ - { title: 'Overview', items: [{ 'A': 'a.md' }] }, -] - `; - // Override with a *different* base to prove the override is respected. - const custom = { HIVE: "docs/custom-hive" }; - const entries = parseNavStructures(src, custom); - expect(entries.every((e) => e.base === "docs/custom-hive")).toBe(true); +// ───────────────────────────────────────────────────────────────────────── +// navAliasEntries / parseNavStructure — nav.yaml → nav-alias routes. +// These are the highest-impact tests in this file: a regression here +// silently makes the link checker either permissive (invents nav routes +// that don't exist) or over-strict (misses real nav routes and mass- +// reports valid links as broken). +// ───────────────────────────────────────────────────────────────────────── +const hive = { name: "hive", base: "docs/hive", navPath: "docs/content/hive/nav.yaml" }; + +describe("navAliasEntries", () => { + it("returns an empty list for an empty nav", () => { + expect(navAliasEntries(hive, [])).toEqual([]); + }); + + it("emits one entry per section slug PLUS one bare entry", () => { + // Mirrors the main-script rule: register the route under every + // section slug AND once with no section prefix. + const entries = navAliasEntries(hive, [ + { title: "Overview", items: [{ Introduction: "readme.md" }] }, + ]); + expect(entries).toEqual([ + { navName: "hive", base: "docs/hive", sectionSlug: "overview", slug: "introduction", title: "Introduction", file: "readme.md" }, + { navName: "hive", base: "docs/hive", sectionSlug: "", slug: "introduction", title: "Introduction", file: "readme.md" }, + ]); + expect(new Set(entries.map(navEntryRoute))).toEqual( + new Set(["/docs/hive/overview/introduction", "/docs/hive/introduction"]), + ); + }); + + it("emits an entry for every (section × entry) combination when a nav has multiple sections", () => { + // The main script over-approximates by registering an entry under + // EVERY section slug in the nav, not just the one it lexically + // belongs to. That is deliberate (safe: only adds valid routes). + // Locking that behavior in with a test protects against a "fix" + // that changes it and mass-reports real links as broken. + const routes = new Set( + navAliasEntries(hive, [ + { title: "Overview", items: [{ Intro: "intro.md" }] }, + { title: "Guides", items: [] }, + ]).map(navEntryRoute), + ); + expect(routes).toEqual( + new Set(["/docs/hive/overview/intro", "/docs/hive/guides/intro", "/docs/hive/intro"]), + ); + }); + + it("descends into nested folders and emits their leaf pages", () => { + const files = navAliasEntries(hive, [ + { title: "S", items: [{ Folder: [{ Leaf: "a/leaf.md" }, { Deeper: [{ Deep: "a/b/deep.md" }] }] }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["a/leaf.md", "a/b/deep.md"])); + }); + + it("skips entries whose file value is an http(s) URL", () => { + const files = navAliasEntries(hive, [ + { title: "S", items: [{ External: "https://example.com/foo.md" }, { Real: "readme.md" }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["readme.md"])); + }); + + it("skips entries whose file value is a root-absolute path", () => { + const files = navAliasEntries(hive, [ + { title: "S", items: [{ Root: "/other-project/readme.md" }, { Real: "readme.md" }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["readme.md"])); + }); + + it("accepts .md and .mdx file extensions but nothing else", () => { + const files = navAliasEntries(hive, [ + { title: "S", items: [{ A: "a.md" }, { B: "b.mdx" }, { C: "c.txt" }, { D: "d" }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["a.md", "b.mdx"])); + }); + + it("ignores bare-string items (they have no title to alias from)", () => { + expect(navAliasEntries(hive, [{ title: "S", items: ["readme.md"] }])).toEqual([]); + }); + + it("slugifies titles the same way slugify() does — punctuation collapses", () => { + const [sectioned, bare] = navAliasEntries(hive, [ + { title: "Whats New", items: [{ "v1.2.3 release notes": "v1.md" }] }, + ]); + expect(sectioned.sectionSlug).toBe("whats-new"); + expect(sectioned.slug).toBe("v1-2-3-release-notes"); + expect(bare.sectionSlug).toBe(""); + expect(navEntryRoute(sectioned)).toBe("/docs/hive/whats-new/v1-2-3-release-notes"); + }); + + it("handles the root docs base (no sub-slug)", () => { + const [entry] = navAliasEntries( + { name: "kubestellar", base: "docs" }, + [{ title: "S", items: [{ "Getting Started": "getting-started.md" }] }], + ); + expect(navEntryRoute(entry)).toBe("/docs/s/getting-started"); + }); +}); + +describe("parseNavStructure", () => { + it("parses YAML and yields the same entries as navAliasEntries", () => { + const yaml = "- title: Overview\n items:\n - Introduction: readme.md\n"; + expect(parseNavStructure(hive, yaml).map(navEntryRoute)).toEqual([ + "/docs/hive/overview/introduction", + "/docs/hive/introduction", + ]); + }); + + it("rejects a malformed nav with a NavFileError naming the file", () => { + expect(() => parseNavStructure(hive, "- title: X\n items: nope\n")).toThrow( + /Invalid sidebar nav file docs\/content\/hive\/nav\.yaml: .*"items" must be a list/, + ); }); }); diff --git a/scripts/check-internal-links-helpers.ts b/scripts/check-internal-links-helpers.ts index dd6457864f..9909a3ea81 100644 --- a/scripts/check-internal-links-helpers.ts +++ b/scripts/check-internal-links-helpers.ts @@ -3,16 +3,22 @@ * * Keeping these in a side-effect-free module lets the vitest suite import them * without triggering the CLI script's top-level filesystem reads (contentRoot - * walk, page-map.ts parse). The main script re-imports and reuses them. + * walk, nav.yaml reads). The main script re-imports and reuses them. * * All helpers here are deterministic and only use path/string primitives. */ import path from "node:path"; +import { + NAV_FILE_NAME, + parseNavYaml, + type NavItem, + type NavSection, +} from "../src/lib/nav"; /** * Convert a human-readable nav title into a URL slug. * - * Mirrors the slug rule that buildPageMap() applies to NAV_STRUCTURE_* entries + * Mirrors the slug rule that buildPageMap() applies to nav.yaml entries * in src/app/docs/page-map.ts, so nav-aliased routes come out the same on both * sides of the check. * @@ -70,35 +76,60 @@ export const ASSET_EXT = /\.(png|jpe?g|gif|svg|webp|avif|ico|pdf|mp4|webm|mov|zip|gz|tgz|css|js|woff2?|ttf|eot)$/i; /** - * Map from a NAV_STRUCTURE_ variable name to the docs base path its - * entries live under. Exported so the main script and its tests share - * one source of truth (drift here would silently break nav-alias - * resolution). + * One nav.yaml to derive nav-alias routes from: the docs base path its + * entries route under (`docs` for the root KubeStellar project, `docs/` + * for every other project and for the cross-project sections, which route + * under plain `docs`) plus the repo-relative path of the YAML file. */ -export const PROJECT_FOR_NAV: Record = { - A2A: "docs/a2a", - MULTI_PLUGIN: "docs/multi-plugin", - KUBEFLEX: "docs/kubeflex", - KUBESTELLAR_MCP: "docs/kubestellar-mcp", - CONSOLE: "docs/console", - HIVE: "docs/hive", - KUBESTELLAR: "docs", -}; +export interface NavSource { + /** Diagnostic label, e.g. 'console' or 'contributing'. */ + name: string; + /** Base path the entries route under, e.g. 'docs/console' or 'docs'. */ + base: string; + /** Repo-relative path of the nav.yaml, e.g. 'docs/content/console/nav.yaml'. */ + navPath: string; +} /** - * One nav-alias entry extracted from a page-map.ts NAV_STRUCTURE_* block. - * Represents the `{ 'Title': 'file.md' }` pairs that buildPageMap() maps - * to a nav-slug route on the site. + * Build the list of nav.yaml sources the checker reads, from the same + * PROJECTS table and GENERAL_SECTIONS list the site uses. Data-driven so a + * new project (or cross-project section) is covered the moment it exists, + * with no parallel table to keep in sync (kubestellar/docs#7080). + */ +export function navSourcesFor( + projects: Record, + generalSections: readonly string[], + contentRoot = "docs/content", +): NavSource[] { + const sources: NavSource[] = Object.values(projects).map((p) => ({ + name: p.id, + base: p.basePath ? `docs/${p.basePath}` : "docs", + navPath: p.navPath, + })); + for (const section of generalSections) { + sources.push({ + name: section, + base: "docs", + navPath: `${contentRoot}/${section}/${NAV_FILE_NAME}`, + }); + } + return sources; +} + +/** + * One nav-alias entry extracted from a nav.yaml. Represents the + * `Title: file.md` pairs that buildPageMap() maps to a nav-slug route on + * the site. */ export interface NavAliasEntry { - /** The uppercase NAV_STRUCTURE_ suffix, e.g. 'HIVE'. */ + /** The NavSource name this entry came from, e.g. 'console'. */ navName: string; - /** Base path resolved via PROJECT_FOR_NAV, e.g. 'docs/hive'. */ + /** Base path from the NavSource, e.g. 'docs/console'. */ base: string; /** * The section slug this entry falls under, or "" if it is a * bare (top-level) entry. Sections are the slugified `title:` fields - * of category objects in the NAV_STRUCTURE block. + * of the top-level sections in the nav file. */ sectionSlug: string; /** The slug derived from the entry's title (via slugify()). */ @@ -110,75 +141,75 @@ export interface NavAliasEntry { } /** - * Extract every nav-alias entry from a page-map.ts source string. + * Extract every nav-alias entry from one parsed nav file. * - * This is the untangled version of the top-level regex loop that - * check-internal-links.ts runs at startup. Keeping it pure and side- - * effect-free lets tests feed synthetic page-map source and assert on - * the exact set of entries produced — critical because a regex - * regression here silently makes the link checker permissive - * (invents nav routes that don't exist) or over-strict (misses real - * nav routes and reports valid links as broken). + * Pure and side-effect-free so tests can feed synthetic nav data and assert + * on the exact set of entries produced — critical because a regression here + * silently makes the link checker permissive (invents nav routes that don't + * exist) or over-strict (misses real nav routes and reports valid links as + * broken). * - * The function does NOT filter entries by file existence — callers - * decide whether an entry is registered as a valid route. That - * separation makes both halves independently testable. + * The function does NOT filter entries by file existence — callers decide + * whether an entry is registered as a valid route. * - * Parsing rules (must mirror buildPageMap in src/app/docs/page-map.ts): - * - Each `const NAV_STRUCTURE_ ... = [ ... ]` block is one nav. - * - Category objects `{ title: 'X', items: [...] }` contribute section - * slugs; slugify(X) is used as the section prefix. - * - `{ 'Title': 'file.md' }` (or "double quoted") pairs inside a nav - * block are entries; slugify('Title') is the slug. - * - Entries whose file starts with `http` or `/` are external and - * skipped. - * - Every entry is emitted once per section slug in the block AND - * once with `sectionSlug: ""` (bare form), mirroring the two - * validRoutes.add() calls the main script makes. + * Rules (must mirror buildPageMap in src/app/docs/page-map.ts): + * - Each top-level `{ title, items }` section contributes a section slug + * (slugify(title)). + * - Every `Title: file.md(x)` pair anywhere in the file (including nested + * folders) is an entry; slugify(Title) is the slug. + * - Entries whose file starts with `http` or `/` are external and skipped; + * values without a .md/.mdx extension are not doc files and are skipped. + * - Every entry is emitted once per section slug in the file AND once + * with `sectionSlug: ""` (bare form), mirroring the two + * validRoutes.add() calls the main script makes. This deliberately + * over-approximates (an entry is registered under every section, not + * just its own) — safe, because it only ever adds routes that the + * flat route set would otherwise already accept. */ -export function parseNavStructures( - pageMapSrc: string, - projectForNav: Record = PROJECT_FOR_NAV, +export function navAliasEntries( + source: Pick, + sections: NavSection[], ): NavAliasEntry[] { - const results: NavAliasEntry[] = []; - const navBlockRe = /const NAV_STRUCTURE_([A-Z_]+)[^=]*=\s*(\[[\s\S]*?\n\])/g; - let nav: RegExpExecArray | null; - while ((nav = navBlockRe.exec(pageMapSrc))) { - const navName = nav[1]; - const base = projectForNav[navName]; - if (!base) continue; - const block = nav[2]; - - // Category section slugs come from `title:` bareword fields on - // category objects. These are slugified and used as path prefixes. - const sections: string[] = []; - const catRe = /title:\s*['"]([^'"]+)['"]/g; - let c: RegExpExecArray | null; - while ((c = catRe.exec(block))) sections.push(slugify(c[1])); + const sectionSlugs = sections.map((s) => slugify(s.title)); - // Entry pairs are `'Title': 'file.md'` or `"Title": "file.md"`. - const titleFileRe = /['"]([^'"]+)['"]\s*:\s*['"]([^'"]+\.mdx?)['"]/g; - const entries: { title: string; file: string; slug: string }[] = []; - let m: RegExpExecArray | null; - while ((m = titleFileRe.exec(block))) { - const title = m[1]; - const file = m[2]; - if (file.startsWith("http") || file.startsWith("/")) continue; - entries.push({ title, file, slug: slugify(title) }); + const entries: { title: string; file: string; slug: string }[] = []; + const visit = (items: NavItem[]) => { + for (const item of items) { + if (typeof item === "string") continue; + for (const [title, value] of Object.entries(item)) { + if (Array.isArray(value)) { + visit(value); + } else if (typeof value === "string") { + if (value.startsWith("http") || value.startsWith("/")) continue; + if (!/\.mdx?$/i.test(value)) continue; + entries.push({ title, file: value, slug: slugify(title) }); + } + } } + }; + for (const section of sections) visit(section.items); - // Emit each entry once per section prefix and once bare, matching - // the two `validRoutes.add(...)` calls the main script makes. - for (const { title, file, slug } of entries) { - for (const sectionSlug of sections) { - results.push({ navName, base, sectionSlug, slug, title, file }); - } - results.push({ navName, base, sectionSlug: "", slug, title, file }); + const results: NavAliasEntry[] = []; + for (const { title, file, slug } of entries) { + for (const sectionSlug of sectionSlugs) { + results.push({ navName: source.name, base: source.base, sectionSlug, slug, title, file }); } + results.push({ navName: source.name, base: source.base, sectionSlug: "", slug, title, file }); } return results; } +/** + * Parse nav YAML source text and extract its nav-alias entries. Thin wrapper + * over parseNavYaml() (schema validation) + navAliasEntries(). + */ +export function parseNavStructure( + source: NavSource, + yamlSource: string, +): NavAliasEntry[] { + return navAliasEntries(source, parseNavYaml(source.navPath, yamlSource)); +} + /** * Turn a nav-alias entry into the site route it registers. * diff --git a/scripts/check-internal-links.ts b/scripts/check-internal-links.ts index f8a028e4a0..58141f9078 100644 --- a/scripts/check-internal-links.ts +++ b/scripts/check-internal-links.ts @@ -18,7 +18,7 @@ * (generateStaticParams strips the extension). This is the flat/direct * route set. * 2. buildPageMap() adds nav routes whose slug derives from the nav title - * (e.g. hive `readme.md` -> `/docs/hive/overview/introduction`). + * (e.g. console `readme.md` -> `/docs/console/overview/introduction`). * A link is valid if it resolves to a route in EITHER set. */ import fs from "fs"; @@ -29,10 +29,12 @@ import { normRoute, isExternalOrAnchor, ASSET_EXT, - PROJECT_FOR_NAV, - parseNavStructures, + navSourcesFor, + parseNavStructure, navEntryRoute, } from "./check-internal-links-helpers"; +import { PROJECTS } from "../src/config/versions/lookup"; +import { GENERAL_SECTIONS } from "../src/lib/nav"; const contentRoot = path.join(process.cwd(), "docs", "content"); @@ -59,36 +61,34 @@ function collectFlatRoutes(dir: string) { collectFlatRoutes(contentRoot); // (2) Nav-alias routes. Some pages are ALSO served under a nav-section path -// whose slug derives from the nav title (e.g. hive `readme.md` is aliased at -// `/docs/hive/overview/introduction`). We derive these from the same page-map -// source the site uses, WITHOUT importing it directly (page-map.ts pulls in -// nextra's bundler-only `normalizePageMap` export, which does not resolve under -// a plain Node/tsx run). Instead we parse the NAV_STRUCTURE_* tables out of -// page-map.ts and replicate its title->slug rule. This keeps the checker a -// zero-heavy-dependency, deterministic gate. -const pageMapSrc = fs.readFileSync( - path.join(process.cwd(), "src", "app", "docs", "page-map.ts"), - "utf8" -); -// slugify, normRoute, isExternalOrAnchor and ASSET_EXT are imported from -// ./check-internal-links-helpers.ts (side-effect free, unit-tested). - +// whose slug derives from the nav title (e.g. console `readme.md` is aliased at +// `/docs/console/overview/introduction`). We derive these from the same +// nav.yaml files the site loads (PROJECTS[id].navPath plus the cross-project +// GENERAL_SECTIONS), replicating buildPageMap's title->slug rule, WITHOUT +// importing page-map.ts (it pulls in nextra's bundler-only `normalizePageMap` +// export, which does not resolve under a plain Node/tsx run). This keeps the +// checker a zero-heavy-dependency, deterministic gate, and a malformed nav.yaml +// fails it with the same NavFileError the site build would raise. +// // Extract every nav-alias entry, then filter to those whose target file // actually exists on disk, and register both the sectioned and the bare -// form as valid routes. Splitting the pure parse (parseNavStructures) +// form as valid routes. Splitting the pure parse (parseNavStructure) // from this fs-touching registration keeps the parser unit-testable. -for (const entry of parseNavStructures(pageMapSrc, PROJECT_FOR_NAV)) { - const fileAbs = path.join( - contentRoot, - entry.base.replace(/^docs\/?/, ""), - entry.file, - ); - if (!fs.existsSync(fileAbs) && entry.base !== "docs") { - // Kubestellar nav references live under docs/content directly. - const alt = path.join(contentRoot, entry.file); - if (!fs.existsSync(alt)) continue; +for (const source of navSourcesFor(PROJECTS, GENERAL_SECTIONS)) { + const yamlSrc = fs.readFileSync(path.join(process.cwd(), source.navPath), "utf8"); + for (const entry of parseNavStructure(source, yamlSrc)) { + const fileAbs = path.join( + contentRoot, + entry.base.replace(/^docs\/?/, ""), + entry.file, + ); + if (!fs.existsSync(fileAbs) && entry.base !== "docs") { + // Cross-project section references live under docs/content directly. + const alt = path.join(contentRoot, entry.file); + if (!fs.existsSync(alt)) continue; + } + validRoutes.add(navEntryRoute(entry)); } - validRoutes.add(navEntryRoute(entry)); } // Normalize a route for comparison: use the shared helper. diff --git a/src/__tests__/check-internal-links-helpers.test.ts b/src/__tests__/check-internal-links-helpers.test.ts index 551c9376ea..530604f125 100644 --- a/src/__tests__/check-internal-links-helpers.test.ts +++ b/src/__tests__/check-internal-links-helpers.test.ts @@ -8,20 +8,24 @@ * every PR touching content). */ import { describe, it, expect } from "vitest"; +import { existsSync } from "node:fs"; import { slugify, normRoute, isExternalOrAnchor, ASSET_EXT, resolveInternalLink, - PROJECT_FOR_NAV, - parseNavStructures, + navAliasEntries, navEntryRoute, + navSourcesFor, + parseNavStructure, type NavAliasEntry, } from "../../scripts/check-internal-links-helpers"; +import { PROJECTS } from "../config/versions/lookup"; +import { GENERAL_SECTIONS } from "../lib/nav"; // ───────────────────────────────────────────────────────────────────────── -// slugify — must match the rule buildPageMap() uses on NAV_STRUCTURE_* titles +// slugify — must match the rule buildPageMap() uses on nav.yaml titles // ───────────────────────────────────────────────────────────────────────── describe("slugify", () => { it("lowercases ASCII", () => { @@ -267,251 +271,156 @@ describe("resolveInternalLink", () => { }); // ───────────────────────────────────────────────────────────────────────── -// PROJECT_FOR_NAV — a regression here would silently break every nav -// alias for the affected project. +// navSourcesFor — derives the nav.yaml list from PROJECTS + GENERAL_SECTIONS +// (kubestellar/docs#7080). A regression here silently drops a project's nav +// aliases from the route table and mass-reports its links as broken. // ───────────────────────────────────────────────────────────────────────── -describe("PROJECT_FOR_NAV", () => { - it("maps every documented project name to its docs base path", () => { - expect(PROJECT_FOR_NAV).toEqual({ - A2A: "docs/a2a", - MULTI_PLUGIN: "docs/multi-plugin", - KUBEFLEX: "docs/kubeflex", - KUBESTELLAR_MCP: "docs/kubestellar-mcp", - CONSOLE: "docs/console", - HIVE: "docs/hive", - KUBESTELLAR: "docs", - }); - }); - - it("uses docs/ for every non-kubestellar project", () => { - // The root Kubestellar project lives at docs/ (no sub-slug); every - // other project must be under docs/. This invariant is what - // makes route registration deterministic — breaking it would move - // nav aliases to the wrong path and mass-report broken links. - for (const [name, base] of Object.entries(PROJECT_FOR_NAV)) { - if (name === "KUBESTELLAR") { - expect(base).toBe("docs"); - } else { - expect(base.startsWith("docs/")).toBe(true); - expect(base).not.toBe("docs"); - } - } +describe("navSourcesFor", () => { + const projects = { + kubestellar: { id: "kubestellar", basePath: "", navPath: "docs/content/nav.yaml" }, + console: { id: "console", basePath: "console", navPath: "docs/content/console/nav.yaml" }, + }; + + it("maps the root project (empty basePath) to bare 'docs'", () => { + const [ks] = navSourcesFor(projects, []); + expect(ks).toEqual({ name: "kubestellar", base: "docs", navPath: "docs/content/nav.yaml" }); }); -}); -// ───────────────────────────────────────────────────────────────────────── -// parseNavStructures — the untangled NAV_STRUCTURE_* regex loop. -// These are the highest-impact tests in this file: a regex regression -// here silently makes the link checker either permissive (invents nav -// routes that don't exist) or over-strict (misses real nav routes and -// mass-reports valid links as broken). -// ───────────────────────────────────────────────────────────────────────── -describe("parseNavStructures", () => { - it("returns an empty list on empty source", () => { - expect(parseNavStructures("")).toEqual([]); + it("maps every other project to docs/ and its own navPath", () => { + const sources = navSourcesFor(projects, []); + expect(sources[1]).toEqual({ name: "console", base: "docs/console", navPath: "docs/content/console/nav.yaml" }); }); - it("returns an empty list when no NAV_STRUCTURE_* declarations are present", () => { - const src = "const something = { title: 'X' };\nexport default {};\n"; - expect(parseNavStructures(src)).toEqual([]); + it("appends one 'docs'-based source per general section, under /
/nav.yaml", () => { + const sources = navSourcesFor(projects, ["contributing", "news"]); + expect(sources.slice(2)).toEqual([ + { name: "contributing", base: "docs", navPath: "docs/content/contributing/nav.yaml" }, + { name: "news", base: "docs", navPath: "docs/content/news/nav.yaml" }, + ]); }); - it("skips NAV_STRUCTURE_* blocks whose name is not in projectForNav", () => { - const src = ` - const NAV_STRUCTURE_UNKNOWN = [ - { 'Intro': 'intro.md' }, -] -`; - expect(parseNavStructures(src)).toEqual([]); + it("covers every real project and general section with an existing nav.yaml", () => { + for (const s of navSourcesFor(PROJECTS, GENERAL_SECTIONS)) { + expect(existsSync(s.navPath), `${s.name}: ${s.navPath}`).toBe(true); + } }); +}); - it("extracts a single bare entry from a HIVE block with no sections", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Introduction': 'readme.md' }, -] -`; - const entries = parseNavStructures(src); - // Bare-form only (no section). - expect(entries).toEqual([ - { - navName: "HIVE", - base: "docs/hive", - sectionSlug: "", - slug: "introduction", - title: "Introduction", - file: "readme.md", - }, - ]); - expect(navEntryRoute(entries[0])).toBe("/docs/hive/introduction"); +// ───────────────────────────────────────────────────────────────────────── +// navAliasEntries / parseNavStructure — nav.yaml → nav-alias routes. +// These are the highest-impact tests in this file: a regression here +// silently makes the link checker either permissive (invents nav routes +// that don't exist) or over-strict (misses real nav routes and mass- +// reports valid links as broken). +// ───────────────────────────────────────────────────────────────────────── +const hive = { name: "hive", base: "docs/hive", navPath: "docs/content/hive/nav.yaml" }; + +describe("navAliasEntries", () => { + it("returns an empty list for an empty nav", () => { + expect(navAliasEntries(hive, [])).toEqual([]); }); it("emits one entry per section slug PLUS one bare entry", () => { // Mirrors the main-script rule: register the route under every // section slug AND once with no section prefix. - const src = ` - const NAV_STRUCTURE_HIVE = [ - { title: 'Overview', items: [ { 'Introduction': 'readme.md' } ] }, -] -`; - const entries = parseNavStructures(src); - expect(entries).toHaveLength(2); + const entries = navAliasEntries(hive, [ + { title: "Overview", items: [{ Introduction: "readme.md" }] }, + ]); + expect(entries).toEqual([ + { navName: "hive", base: "docs/hive", sectionSlug: "overview", slug: "introduction", title: "Introduction", file: "readme.md" }, + { navName: "hive", base: "docs/hive", sectionSlug: "", slug: "introduction", title: "Introduction", file: "readme.md" }, + ]); expect(new Set(entries.map(navEntryRoute))).toEqual( new Set(["/docs/hive/overview/introduction", "/docs/hive/introduction"]), ); }); - it("emits an entry for every (section × entry) combination when a block has multiple sections", () => { + it("emits an entry for every (section × entry) combination when a nav has multiple sections", () => { // The main script over-approximates by registering an entry under - // EVERY section slug in the block, not just the one it lexically + // EVERY section slug in the nav, not just the one it lexically // belongs to. That is deliberate (safe: only adds valid routes). // Locking that behavior in with a test protects against a "fix" // that changes it and mass-reports real links as broken. - const src = ` - const NAV_STRUCTURE_HIVE = [ - { title: 'Overview', items: [ { 'Intro': 'intro.md' } ] }, - { title: 'Guides', items: [] }, -] -`; - const routes = new Set(parseNavStructures(src).map(navEntryRoute)); + const routes = new Set( + navAliasEntries(hive, [ + { title: "Overview", items: [{ Intro: "intro.md" }] }, + { title: "Guides", items: [] }, + ]).map(navEntryRoute), + ); expect(routes).toEqual( - new Set([ - "/docs/hive/overview/intro", - "/docs/hive/guides/intro", - "/docs/hive/intro", - ]), + new Set(["/docs/hive/overview/intro", "/docs/hive/guides/intro", "/docs/hive/intro"]), ); }); + it("descends into nested folders and emits their leaf pages", () => { + const files = navAliasEntries(hive, [ + { title: "S", items: [{ Folder: [{ Leaf: "a/leaf.md" }, { Deeper: [{ Deep: "a/b/deep.md" }] }] }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["a/leaf.md", "a/b/deep.md"])); + }); + it("skips entries whose file value is an http(s) URL", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'External': 'https://example.com/foo.md' }, - { 'Real': 'readme.md' }, -] -`; - const files = parseNavStructures(src).map(e => e.file); - expect(files).toEqual(["readme.md"]); + const files = navAliasEntries(hive, [ + { title: "S", items: [{ External: "https://example.com/foo.md" }, { Real: "readme.md" }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["readme.md"])); }); it("skips entries whose file value is a root-absolute path", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Root': '/other-project/readme.md' }, - { 'Real': 'readme.md' }, -] -`; - const files = parseNavStructures(src).map(e => e.file); - expect(files).toEqual(["readme.md"]); - }); - - it("accepts both single- and double-quoted title:file pairs", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Single': 'a.md' }, - { "Double": "b.md" }, -] -`; - const slugs = parseNavStructures(src).map(e => e.slug); - expect(slugs).toEqual(["single", "double"]); + const files = navAliasEntries(hive, [ + { title: "S", items: [{ Root: "/other-project/readme.md" }, { Real: "readme.md" }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["readme.md"])); }); it("accepts .md and .mdx file extensions but nothing else", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'A': 'a.md' }, - { 'B': 'b.mdx' }, - { 'C': 'c.txt' }, - { 'D': 'd' }, -] -`; - const files = parseNavStructures(src).map(e => e.file); - expect(files).toEqual(["a.md", "b.mdx"]); + const files = navAliasEntries(hive, [ + { title: "S", items: [{ A: "a.md" }, { B: "b.mdx" }, { C: "c.txt" }, { D: "d" }] }, + ]).map((e) => e.file); + expect(new Set(files)).toEqual(new Set(["a.md", "b.mdx"])); + }); + + it("ignores bare-string items (they have no title to alias from)", () => { + expect(navAliasEntries(hive, [{ title: "S", items: ["readme.md"] }])).toEqual([]); }); it("slugifies titles the same way slugify() does — punctuation collapses", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { title: "Whats New", items: [ { 'v1.2.3 release notes': 'v1.md' } ] }, -] -`; - const [sectioned, bare] = parseNavStructures(src); -expect(sectioned.sectionSlug).toBe("whats-new"); + const [sectioned, bare] = navAliasEntries(hive, [ + { title: "Whats New", items: [{ "v1.2.3 release notes": "v1.md" }] }, + ]); + expect(sectioned.sectionSlug).toBe("whats-new"); expect(sectioned.slug).toBe("v1-2-3-release-notes"); expect(bare.sectionSlug).toBe(""); - expect(navEntryRoute(sectioned)).toBe( - "/docs/hive/whats-new/v1-2-3-release-notes", + expect(navEntryRoute(sectioned)).toBe("/docs/hive/whats-new/v1-2-3-release-notes"); + }); + + it("handles the root docs base (no sub-slug)", () => { + const [entry] = navAliasEntries( + { name: "kubestellar", base: "docs" }, + [{ title: "S", items: [{ "Getting Started": "getting-started.md" }] }], ); + expect(navEntryRoute(entry)).toBe("/docs/s/getting-started"); }); +}); - it("independently parses multiple NAV_STRUCTURE_* blocks in one source", () => { - const src = ` - const NAV_STRUCTURE_HIVE = [ - { 'Intro': 'intro.md' }, -] - const NAV_STRUCTURE_CONSOLE = [ - { 'Home': 'home.md' }, -] -`; - const byNav = new Map(); - for (const e of parseNavStructures(src)) { - const list = byNav.get(e.navName) ?? []; - list.push(navEntryRoute(e)); - byNav.set(e.navName, list); - } - expect(byNav.get("HIVE")).toEqual(["/docs/hive/intro"]); - expect(byNav.get("CONSOLE")).toEqual(["/docs/console/home"]); - }); - - it("handles the KUBESTELLAR base (root docs path, no sub-slug)", () => { - const src = ` - const NAV_STRUCTURE_KUBESTELLAR = [ - { 'Getting Started': 'getting-started.md' }, -] -`; - const entries = parseNavStructures(src); - expect(navEntryRoute(entries[0])).toBe("/docs/getting-started"); - }); - - it("supports an override of PROJECT_FOR_NAV for injection in tests", () => { - const src = ` - const NAV_STRUCTURE_CUSTOM = [ - { 'X': 'x.md' }, -] -`; - const entries = parseNavStructures(src, { CUSTOM: "docs/injected" }); - expect(entries).toHaveLength(1); - expect(entries[0].base).toBe("docs/injected"); - expect(navEntryRoute(entries[0])).toBe("/docs/injected/x"); - }); - - it("does not confuse `title:` fields inside NAV_STRUCTURE with entry pairs", () => { - // Regression guard: the entry-pair regex is quoted-key based, so - // an unquoted `title: '...'` field must not be picked up as an - // entry. If someone loosens the entry regex, we want this to fail. - const src = ` - const NAV_STRUCTURE_HIVE = [ - { title: 'Overview', items: [] }, -] -`; - const entries = parseNavStructures(src); - expect(entries).toEqual([]); - }); - - it("does not match a NAV_STRUCTURE reference that is not a declaration", () => { - // Only `const NAV_STRUCTURE_` declarations should be parsed — - // uses/references to the same name in other positions must be - // ignored so we don't double-count. - const src = ` - const other = NAV_STRUCTURE_HIVE; - export { NAV_STRUCTURE_HIVE }; - `; - expect(parseNavStructures(src)).toEqual([]); +describe("parseNavStructure", () => { + it("parses YAML and yields the same entries as navAliasEntries", () => { + const yaml = "- title: Overview\n items:\n - Introduction: readme.md\n"; + expect(parseNavStructure(hive, yaml).map(navEntryRoute)).toEqual([ + "/docs/hive/overview/introduction", + "/docs/hive/introduction", + ]); + }); + + it("rejects a malformed nav with a NavFileError naming the file", () => { + expect(() => parseNavStructure(hive, "- title: X\n items: nope\n")).toThrow( + /Invalid sidebar nav file docs\/content\/hive\/nav\.yaml: .*"items" must be a list/, + ); }); }); // ───────────────────────────────────────────────────────────────────────── -// navEntryRoute — trivial but pinned by contract with parseNavStructures. +// navEntryRoute — trivial but pinned by contract with navAliasEntries. // ───────────────────────────────────────────────────────────────────────── describe("navEntryRoute", () => { const base: NavAliasEntry = { diff --git a/src/__tests__/nav.test.ts b/src/__tests__/nav.test.ts new file mode 100644 index 0000000000..5e7d556381 --- /dev/null +++ b/src/__tests__/nav.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + GENERAL_SECTIONS, + NAV_FILE_NAME, + NavFileError, + isGeneralSectionFile, + loadNavFile, + navItemTouchesGeneralSection, + parseNavYaml, + validateNavSections, +} from '../lib/nav' +import { PROJECTS } from '../config/versions' + +/** + * Coverage for src/lib/nav.ts — the nav.yaml schema, validator and loader + * that replaced the ten hand-maintained NAV_STRUCTURE_* literals in + * src/app/docs/page-map.ts (kubestellar/docs#7080). + * + * The validator is what turns "a docs author typo'd nav.yaml" into a build + * error with a file + location instead of an `undefined` spread deep inside + * buildNavNodes, so every rejection path is pinned here. + */ + +describe('GENERAL_SECTIONS / isGeneralSectionFile', () => { + it('lists exactly the three cross-project sections, in sidebar order', () => { + expect(GENERAL_SECTIONS).toEqual(['contributing', 'community', 'news']) + }) + + it('matches files under a general-section directory', () => { + expect(isGeneralSectionFile('contributing/contribute.md')).toBe(true) + expect(isGeneralSectionFile('community/index.md')).toBe(true) + expect(isGeneralSectionFile('news/reviews.md')).toBe(true) + expect(isGeneralSectionFile('contributing/documentation/nested.md')).toBe(true) + }) + + it('does not match project files or look-alike names', () => { + expect(isGeneralSectionFile('kubestellar/testing.md')).toBe(false) + expect(isGeneralSectionFile('intro.md')).toBe(false) + // Prefix must be a whole directory segment. + expect(isGeneralSectionFile('community-extras/x.md')).toBe(false) + expect(isGeneralSectionFile('newsletter.md')).toBe(false) + }) +}) + +describe('navItemTouchesGeneralSection', () => { + it('handles bare-string items', () => { + expect(navItemTouchesGeneralSection('news/index.md')).toBe(true) + expect(navItemTouchesGeneralSection('readme.md')).toBe(false) + }) + + it('handles { title: path } items', () => { + expect(navItemTouchesGeneralSection({ Policy: 'contributing/security/security-inc.md' })).toBe(true) + expect(navItemTouchesGeneralSection({ Testing: 'kubestellar/testing.md' })).toBe(false) + }) + + it('only inspects direct string values, not nested folders', () => { + // Mirrors the pre-#7080 Object.values(v).some(typeof val === 'string' …) + // predicate: a folder whose children are themselves folders is judged + // by its own (folder) children, not recursively. + expect(navItemTouchesGeneralSection({ Folder: [{ Leaf: 'contributing/x.md' }] })).toBe(false) + }) +}) + +describe('validateNavSections', () => { + const FILE = 'docs/content/x/nav.yaml' + + it('accepts the canonical shape and returns it normalised', () => { + const out = validateNavSections(FILE, [ + { title: 'Overview', items: [{ Introduction: 'intro.md' }, 'bare.md'] }, + { title: 'Guides', items: [{ Folder: [{ Child: 'a/child.md' }, { Deeper: [{ Leaf: 'a/b/leaf.md' }] }] }] }, + { title: 'Links', items: [{ Site: 'https://example.com' }, { Abs: '/docs/elsewhere' }] }, + ]) + expect(out).toEqual([ + { title: 'Overview', items: [{ Introduction: 'intro.md' }, 'bare.md'] }, + { title: 'Guides', items: [{ Folder: [{ Child: 'a/child.md' }, { Deeper: [{ Leaf: 'a/b/leaf.md' }] }] }] }, + { title: 'Links', items: [{ Site: 'https://example.com' }, { Abs: '/docs/elsewhere' }] }, + ]) + }) + + it('accepts an empty section list and an empty items list', () => { + expect(validateNavSections(FILE, [])).toEqual([]) + expect(validateNavSections(FILE, [{ title: 'Empty', items: [] }])).toEqual([{ title: 'Empty', items: [] }]) + }) + + it.each([ + [null, /top level must be a list/], + [undefined, /top level must be a list/], + [{ title: 'X', items: [] }, /top level must be a list/], + ['- oops', /top level must be a list/], + ])('rejects a non-list top level (%j)', (input, message) => { + expect(() => validateNavSections(FILE, input)).toThrow(NavFileError) + expect(() => validateNavSections(FILE, input)).toThrow(message) + }) + + it.each([ + ['a string section', ['Overview'], /section\[0\]: expected \{ title, items \}/], + ['a list section', [[]], /section\[0\]: expected \{ title, items \}/], + ['a null section', [null], /section\[0\]: expected \{ title, items \}/], + ['a missing title', [{ items: [] }], /section\[0\]: "title" must be a non-empty string/], + ['an empty title', [{ title: '', items: [] }], /section\[0\]: "title" must be a non-empty string/], + ['a non-string title', [{ title: 3, items: [] }], /section\[0\]: "title" must be a non-empty string/], + ['missing items', [{ title: 'T' }], /section\[0\] "T": "items" must be a list/], + ['a scalar items', [{ title: 'T', items: 'intro.md' }], /section\[0\] "T": "items" must be a list/], + ['a mapping items', [{ title: 'T', items: { Intro: 'intro.md' } }], /section\[0\] "T": "items" must be a list/], + ])('rejects %s', (_label, input, message) => { + expect(() => validateNavSections(FILE, input)).toThrow(NavFileError) + expect(() => validateNavSections(FILE, input)).toThrow(message) + }) + + it.each([ + ['an empty bare-string path', [''], /items\[0\]: empty path/], + ['a number item', [42], /items\[0\]: expected "Title: path" or "Title: \[children\]"/], + ['a null item', [null], /items\[0\]: expected "Title: path"/], + ['a list item', [['x.md']], /items\[0\]: expected "Title: path"/], + ['an item with zero keys', [{}], /items\[0\]: each item must have exactly one "Title" key, got 0/], + ['an item with two keys', [{ A: 'a.md', B: 'b.md' }], /items\[0\]: each item must have exactly one "Title" key, got 2 \(A, B\)/], + ['an empty path value', [{ A: '' }], /items\[0\] "A": empty path/], + ['a null path value (YAML `Title:` with nothing after)', [{ A: null }], /items\[0\] "A": value must be a path string or a list of items, got null/], + ['a number value', [{ A: 7 }], /items\[0\] "A": value must be a path string or a list of items, got 7/], + ['a mapping value', [{ A: { B: 'b.md' } }], /items\[0\] "A": value must be a path string or a list of items/], + ])('rejects %s inside items', (_label, items, message) => { + const input = [{ title: 'Sec', items }] + expect(() => validateNavSections(FILE, input)).toThrow(NavFileError) + expect(() => validateNavSections(FILE, input)).toThrow(message) + }) + + it('reports the nested location of a bad folder child', () => { + const input = [{ title: 'Sec', items: [{ Folder: [{ Ok: 'ok.md' }, { Bad: 5 }] }] }] + expect(() => validateNavSections(FILE, input)).toThrow( + /section\[0\] "Sec"\.items\[0\] "Folder"\[1\] "Bad": value must be a path string/, + ) + }) + + it('prefixes every error with the file being validated', () => { + expect(() => validateNavSections(FILE, 'nope')).toThrow(`Invalid sidebar nav file ${FILE}:`) + }) +}) + +describe('parseNavYaml', () => { + it('parses valid YAML into sections', () => { + const yaml = [ + '# comment', + '- title: Overview', + ' items:', + ' - Introduction: intro.md', + ' - Guides:', + ' - Quick Start: guides/quick-start.md', + ].join('\n') + expect(parseNavYaml('f.yaml', yaml)).toEqual([ + { title: 'Overview', items: [{ Introduction: 'intro.md' }, { Guides: [{ 'Quick Start': 'guides/quick-start.md' }] }] }, + ]) + }) + + it('wraps YAML syntax errors in NavFileError with the file name', () => { + const bad = '- title: X\n items: [\n' + expect(() => parseNavYaml('f.yaml', bad)).toThrow(NavFileError) + expect(() => parseNavYaml('f.yaml', bad)).toThrow(/Invalid sidebar nav file f\.yaml: YAML parse error:/) + }) + + it('rejects an empty document', () => { + expect(() => parseNavYaml('f.yaml', '')).toThrow(NavFileError) + }) + + it('rejects a document whose top level is a mapping', () => { + expect(() => parseNavYaml('f.yaml', 'title: X\nitems: []\n')).toThrow(/top level must be a list/) + }) +}) + +describe('loadNavFile', () => { + const dirs: string[] = [] + const tmp = () => { + const d = mkdtempSync(join(tmpdir(), 'nav-test-')) + dirs.push(d) + return d + } + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) + }) + + it('reads and validates a nav.yaml from disk', () => { + const dir = tmp() + const file = join(dir, NAV_FILE_NAME) + writeFileSync(file, '- title: Overview\n items:\n - Intro: intro.md\n') + expect(loadNavFile(file)).toEqual([{ title: 'Overview', items: [{ Intro: 'intro.md' }] }]) + }) + + it('throws NavFileError naming the file when it does not exist', () => { + const missing = join(tmp(), 'does-not-exist.yaml') + expect(() => loadNavFile(missing)).toThrow(NavFileError) + expect(() => loadNavFile(missing)).toThrow(/does-not-exist\.yaml: file not found/) + }) + + it('wraps filesystem read errors in NavFileError naming the file', () => { + // A directory at the nav path exists but cannot be read as a file (EISDIR). + const file = join(tmp(), NAV_FILE_NAME) + mkdirSync(file) + expect(() => loadNavFile(file)).toThrow(NavFileError) + expect(() => loadNavFile(file)).toThrow(/nav\.yaml: read error: /) + }) + + it('surfaces schema violations from a real file', () => { + const dir = tmp() + const file = join(dir, NAV_FILE_NAME) + writeFileSync(file, '- title: Overview\n items: intro.md\n') + expect(() => loadNavFile(file)).toThrow(/"items" must be a list/) + }) + + it('loads every real project nav and general-section nav without error', () => { + for (const project of Object.values(PROJECTS)) { + const sections = loadNavFile(join(process.cwd(), project.navPath)) + expect(sections.length, project.id).toBeGreaterThan(0) + } + for (const section of GENERAL_SECTIONS) { + const sections = loadNavFile(join(process.cwd(), 'docs', 'content', section, NAV_FILE_NAME)) + expect(sections).toHaveLength(1) + } + }) + + it('every real nav entry (except external/absolute links) points at an existing page', () => { + // The pre-#7080 failure mode (#5001, #5002, #5067) was pages missing + // from the nav; the inverse — nav pointing at a page that does not + // exist — is now cheap to catch here. Kubestellar-relative fallbacks + // (contributing/, kubestellar/…) resolve against docs/content. + const contentRoot = join(process.cwd(), 'docs', 'content') + const missing: string[] = [] + const check = (base: string, items: ReturnType[number]['items']) => { + for (const item of items) { + const pairs = typeof item === 'string' ? [[item, item] as const] : Object.entries(item) + for (const [, value] of pairs) { + if (Array.isArray(value)) { + check(base, value) + } else if (typeof value === 'string' && !value.startsWith('http') && !value.startsWith('/')) { + if (!existsSync(join(base, value)) && !existsSync(join(contentRoot, value))) { + missing.push(`${base}: ${value}`) + } + } + } + } + } + for (const project of Object.values(PROJECTS)) { + const base = join(process.cwd(), project.contentPath) + for (const section of loadNavFile(join(process.cwd(), project.navPath))) check(base, section.items) + } + for (const section of GENERAL_SECTIONS) { + for (const s of loadNavFile(join(contentRoot, section, NAV_FILE_NAME))) check(contentRoot, s.items) + } + expect(missing).toEqual([]) + }) +}) diff --git a/src/__tests__/page-map-defensive-arms-drift.test.ts b/src/__tests__/page-map-defensive-arms-drift.test.ts index d4b4f8d006..906d995a9a 100644 --- a/src/__tests__/page-map-defensive-arms-drift.test.ts +++ b/src/__tests__/page-map-defensive-arms-drift.test.ts @@ -5,8 +5,8 @@ import { join } from 'node:path' /** * Source-parsing drift-guard for the DEFENSIVE arms inside `buildNavNodes` * (src/app/docs/page-map.ts). These arms cannot currently be exercised at - * runtime because none of the NAV_STRUCTURE_* constants use a bare string - * item or an external/absolute-URL value; but each arm exists to keep the + * runtime because none of the nav.yaml files use a bare string item or an + * external/absolute-URL value; but each arm exists to keep the * general-section routing invariant intact when future navs are added. * * If a future refactor silently deletes or narrows any of these arms, this @@ -14,28 +14,31 @@ import { join } from 'node:path' * runtime-exercised checks in page-map-general-section-arms.test.ts by * pinning the arms that only fire under future nav shapes. * - * Invariants locked (see kubestellar/docs#6663 for context): + * Invariants locked (see kubestellar/docs#6663 and #7080 for context): * - * 1. buildPageMap merges general-section files (contributing/, community/, - * news/) from the main kubestellar docs dir into every non-kubestellar - * project's allDocFiles — plus the three root-level shared pages. + * 1. buildPageMap merges general-section files (every GENERAL_SECTIONS + * prefix) from the main kubestellar docs dir into every non-kubestellar + * project's allDocFiles — plus the ROOT_SHARED_PAGES. * - * 2. Bare-string item arm (arm 3 in page-map-general-section-arms.test.ts) - * routes under /docs/... when the string path starts with any of the - * three general-section prefixes; otherwise under projectBasePath. + * 2. Bare-string item arm routes under /docs/... when the string path is a + * general-section file; otherwise under projectBasePath. * * 3. Object-with-string-value arm routes external links (starts with 'http') * and absolute internal paths (starts with '/') directly, without * re-anchoring them under any project base, and without adding them to * processedFiles (they're not local doc files). * - * 4. Object-with-string-value arm applies the SAME three general-section - * prefixes to string values before deciding route base. + * 4. Object-with-string-value arm applies the SAME general-section test to + * string values before deciding route base. * * 5. Folder-of-children arm inspects nested object values to detect a - * general-section leaf (Object.values(...).some(...)) — required - * because navs frequently wrap contributing/community/news pages - * inside titled subfolders. + * general-section leaf — required because navs frequently wrap + * contributing/community/news pages inside titled subfolders. + * + * 6. There is exactly ONE definition of "general section" — the + * GENERAL_SECTIONS list in src/lib/nav.ts — and page-map.ts contains no + * hard-coded 'contributing/' | 'community/' | 'news/' prefix checks of + * its own (the pre-#7080 shape that had to be edited in four places). */ const SRC = readFileSync( @@ -44,15 +47,10 @@ const SRC = readFileSync( ) describe('buildPageMap: general-section file merge (drift-guard)', () => { - it('merges the three general-section prefixes plus root shared pages when projectId !== kubestellar', () => { - // Line ~547: the filter that pulls general-section files into non-kubestellar projects. - expect(SRC).toContain("f.startsWith('contributing/')") - expect(SRC).toContain("f.startsWith('community/')") - expect(SRC).toContain("f.startsWith('news/')") + it('merges general-section files plus root shared pages when projectId !== kubestellar', () => { + expect(SRC).toMatch(/isGeneralSectionFile\(f\) \|\| \(ROOT_SHARED_PAGES as readonly string\[\]\)\.includes\(f\)/) // Root-level shared pages that render on every project. - expect(SRC).toContain("f === 'intro.md'") - expect(SRC).toContain("f === 'legacy-components.md'") - expect(SRC).toContain("f === 'what-is-console.md'") + expect(SRC).toMatch(/const ROOT_SHARED_PAGES = \['intro\.md', 'legacy-components\.md', 'what-is-console\.md'\] as const/) // The gate — only apply the extra merge for non-kubestellar projects. expect(SRC).toContain("if (projectId !== 'kubestellar')") }) @@ -60,14 +58,8 @@ describe('buildPageMap: general-section file merge (drift-guard)', () => { describe('buildNavNodes: bare-string item arm (drift-guard)', () => { it('has the bare-string branch that routes general-section strings under /docs', () => { - // Arm gate at ~L561. expect(SRC).toContain("if (typeof item === 'string')") - // General-section detection at ~L567 uses exactly the three prefixes. - expect(SRC).toMatch( - /const isGeneralSection = item\.startsWith\('contributing\/'\) \|\| item\.startsWith\('community\/'\) \|\| item\.startsWith\('news\/'\)/, - ) - // basePathForRoute ternary at ~L568 selects 'docs' vs projectBasePath. - expect(SRC).toMatch(/const basePathForRoute = isGeneralSection \? 'docs' : projectBasePath/) + expect(SRC).toMatch(/const basePathForRoute = isGeneralSectionFile\(item\) \? 'docs' : projectBasePath/) }) it('adds the bare-string item to processedFiles and pushes an MdxPage node', () => { @@ -81,75 +73,54 @@ describe('buildNavNodes: bare-string item arm (drift-guard)', () => { describe('buildNavNodes: external-link / absolute-path arm (drift-guard)', () => { it('detects external URLs and absolute internal paths', () => { - // Arm gate at ~L581. expect(SRC).toMatch( /if \(value\.startsWith\('http'\) \|\| value\.startsWith\('\/'\)\)/, ) }) it('emits the value verbatim as the route (no project-base rewrite)', () => { - // Lines 583-584: push MdxPage with route: value, without going through - // any /${basePathForRoute}/... template. If someone "helpfully" wraps - // external links under projectBasePath, every external footer link in - // the docs collapses to a 404 loop. + // If someone "helpfully" wraps external links under projectBasePath, + // every external footer link in the docs collapses to a 404 loop. expect(SRC).toMatch(/nodes\.push\(\{ kind: 'MdxPage', name: title, route: value \}\)/) }) }) describe('buildNavNodes: object-with-string-value general-section arm (drift-guard)', () => { - it('applies the same three prefixes to string values before choosing route base', () => { - // Line ~590. - expect(SRC).toMatch( - /const isGeneralSection = value\.startsWith\('contributing\/'\) \|\| value\.startsWith\('community\/'\) \|\| value\.startsWith\('news\/'\)/, - ) + it('applies the shared general-section test to string values before choosing route base', () => { + expect(SRC).toMatch(/const basePathForRoute = isGeneralSectionFile\(value\) \? 'docs' : projectBasePath/) }) }) describe('buildNavNodes: folder-of-children nested-object detection (drift-guard)', () => { - it('detects a general-section leaf inside a titled subfolder via Object.values(...).some(...)', () => { - // Line ~607-616: when a folder's children are objects (title -> path), - // the folder itself must be routed under /docs if any child value - // points into contributing/community/news. Peeling this back would - // put the whole 'CI/CD' folder under /docs//ci-cd/ instead - // of /docs/ci-cd/, breaking every shared-section cross link. - expect(SRC).toContain('const objValues = Object.values(v);') - expect(SRC).toMatch(/objValues\.some\(val =>/) - // The nested predicate MUST cover the same three prefixes. - expect(SRC).toMatch( - /val\.startsWith\('contributing\/'\) \|\| val\.startsWith\('community\/'\) \|\| val\.startsWith\('news\/'\)/, - ) + it('routes a folder under /docs when any direct child touches a general section', () => { + // Peeling this back would put the whole 'CI/CD' folder under + // /docs//ci-cd/ instead of /docs/ci-cd/, breaking every + // shared-section cross link. + expect(SRC).toMatch(/const basePathForRoute = value\.some\(navItemTouchesGeneralSection\) \? 'docs' : projectBasePath/) }) - it('also handles the direct-string case inside the folder-children detector', () => { - // Same block, string-typed branch: v itself is a string path. - expect(SRC).toMatch( - /v\.startsWith\('contributing\/'\) \|\| v\.startsWith\('community\/'\) \|\| v\.startsWith\('news\/'\)/, - ) + it('routes a top-level category under /docs when its title is a general-section title', () => { + expect(SRC).toMatch(/const generalSectionTitles = new Set\(navStructure\.filter\(c => c\.general\)\.map\(c => c\.title\)\)/) + expect(SRC).toMatch(/const basePath = generalSectionTitles\.has\(category\.title\) \? 'docs' : projectBasePath/) }) }) -describe('buildNavNodes: general-section prefix set is EXACTLY three (drift-guard)', () => { - it('exactly three arms use the tri-prefix general-section check', () => { - // Whole-file count: bare-string arm, object-string-value arm, and - // folder-children direct-string sub-arm. If a fourth or fifth copy - // appears with a divergent prefix set, or one disappears entirely, - // this catches the drift. - const bareStringArm = SRC.match( - /item\.startsWith\('contributing\/'\) \|\| item\.startsWith\('community\/'\) \|\| item\.startsWith\('news\/'\)/g, - ) ?? [] - const valueArm = SRC.match( - /value\.startsWith\('contributing\/'\) \|\| value\.startsWith\('community\/'\) \|\| value\.startsWith\('news\/'\)/g, - ) ?? [] - const nestedStringArm = SRC.match( - /v\.startsWith\('contributing\/'\) \|\| v\.startsWith\('community\/'\) \|\| v\.startsWith\('news\/'\)/g, - ) ?? [] - const nestedValArm = SRC.match( - /val\.startsWith\('contributing\/'\) \|\| val\.startsWith\('community\/'\) \|\| val\.startsWith\('news\/'\)/g, - ) ?? [] - // Plus the getAllDocFiles filter uses `f.startsWith(...)` (see first block). - expect(bareStringArm).toHaveLength(1) - expect(valueArm).toHaveLength(1) - expect(nestedStringArm).toHaveLength(1) - expect(nestedValArm).toHaveLength(1) +describe('general-section prefix set has exactly ONE definition (drift-guard)', () => { + it('page-map.ts has no hard-coded general-section prefix literals', () => { + // The pre-#7080 file repeated `startsWith('contributing/') || ...` in + // four places (plus a title list). All of them now route through + // GENERAL_SECTIONS / isGeneralSectionFile in src/lib/nav.ts. + expect(SRC).not.toMatch(/startsWith\('(contributing|community|news)\/'\)/) + expect(SRC).not.toMatch(/\['Contributing', 'Community', 'News'\]/) + }) + + it('page-map.ts contains no per-project NAV_STRUCTURE_* literals', () => { + expect(SRC).not.toMatch(/NAV_STRUCTURE_/) + }) + + it('each of the three general-section route decisions calls the shared helper exactly once', () => { + expect(SRC.match(/isGeneralSectionFile\(item\)/g) ?? []).toHaveLength(1) + expect(SRC.match(/isGeneralSectionFile\(value\)/g) ?? []).toHaveLength(1) + expect(SRC.match(/value\.some\(navItemTouchesGeneralSection\)/g) ?? []).toHaveLength(1) }) }) diff --git a/src/__tests__/page-map-general-section-arms.test.ts b/src/__tests__/page-map-general-section-arms.test.ts index 1e85c0a491..f68bfd18e5 100644 --- a/src/__tests__/page-map-general-section-arms.test.ts +++ b/src/__tests__/page-map-general-section-arms.test.ts @@ -6,7 +6,7 @@ import type { ProjectId } from '../config/versions' * Coverage for the `isGeneralSection` routing arms inside `buildNavNodes` * in src/app/docs/page-map.ts (see kubestellar/docs#6663). * - * NAV_STRUCTURE_CONTRIBUTING / NAV_STRUCTURE_COMMUNITY / NAV_STRUCTURE_NEWS + * The contributing / community / news navs (docs/content/
/nav.yaml) * are appended by getNavStructure() to every project's baseStructure, so * their pages MUST route under /docs/
/... rather than under the * per-project base (/docs/a2a/contributing/..., /docs/console/community/..., @@ -23,7 +23,7 @@ import type { ProjectId } from '../config/versions' * * 3. bare-string item (page-map.ts ~L561) * A defensive arm for future navs; none of the current - * NAV_STRUCTURE_* constants use bare strings, so we only assert + * nav.yaml files use bare strings, so we only assert * that the top-level route wiring below stays correct — the arm * itself is exercised elsewhere by future nav changes. * @@ -191,7 +191,7 @@ describe('buildNavNodes — non-general folder retains project base path', () => // under the project prefix (they live under /docs/contributing etc. // regardless of project). A project may legitimately have its own // 'community/…' pages inside its contentPath — those are project-owned - // and unrelated to the shared NAV_STRUCTURE_COMMUNITY arm. + // and unrelated to the shared community nav arm. const sharedFolderUnderProject = flat.filter( (n) => n.kind === 'Folder' && diff --git a/src/__tests__/page-map-project-arms.test.ts b/src/__tests__/page-map-project-arms.test.ts index e8aa5fe4bd..553caf5de0 100644 --- a/src/__tests__/page-map-project-arms.test.ts +++ b/src/__tests__/page-map-project-arms.test.ts @@ -6,9 +6,9 @@ import { buildPageMap } from '../app/docs/page-map' * getNavStructure() and the sibling general-files filter in buildPageMap() * inside src/app/docs/page-map.ts: * - * * lines 527-528 -> case 'a2a' -> NAV_STRUCTURE_A2A - * * lines 533-534 -> case 'kubeflex' -> NAV_STRUCTURE_KUBEFLEX - * * lines 542-543 -> case 'multi-plugin' -> NAV_STRUCTURE_MULTI_PLUGIN + * * 'a2a' -> docs/content/a2a/nav.yaml + * * 'kubeflex' -> docs/content/kubeflex/nav.yaml + * * 'multi-plugin' -> docs/content/multi-plugin/nav.yaml * * lines 578-587 -> the `if (projectId !== 'kubestellar')` block that * pulls the shared contributing/community/news * markdown files (plus intro.md, legacy-components.md, @@ -16,7 +16,7 @@ import { buildPageMap } from '../app/docs/page-map' * * Prior tests exercise only 'kubestellar', 'console', 'kubestellar-mcp', * arms. Regressions to the a2a/kubeflex/multi-plugin arms — e.g. - * accidentally routing them to NAV_STRUCTURE_KUBESTELLAR (the default arm) + * accidentally routing them to the KubeStellar nav (docs/content/nav.yaml) * — would go completely unnoticed today. */ @@ -104,7 +104,7 @@ describe("buildPageMap('multi-plugin') — getNavStructure switch arm", () => { it('exposes a "Development" or similar multi-plugin section not present on the default arm', () => { const { pageMap } = buildPageMap('multi-plugin') as unknown as BuildResult const titles = pageMap.map((n) => n.name) - // NAV_STRUCTURE_MULTI_PLUGIN ends with a top-level "Development" folder. + // docs/content/multi-plugin/nav.yaml ends with a top-level "Development" folder. expect(titles).toContain('Development') }) }) diff --git a/src/__tests__/versions.shape-invariants.test.ts b/src/__tests__/versions.shape-invariants.test.ts index 4240e5e92c..4eb199500f 100644 --- a/src/__tests__/versions.shape-invariants.test.ts +++ b/src/__tests__/versions.shape-invariants.test.ts @@ -14,6 +14,8 @@ * that must hold for EVERY project in PROJECTS. */ import { describe, it, expect } from 'vitest' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { PROJECTS, type ProjectId } from '../config/versions' const projectEntries = Object.entries(PROJECTS) as [ProjectId, typeof PROJECTS[ProjectId]][] @@ -109,6 +111,21 @@ describe('PROJECTS shape invariants', () => { }, ) + // ----------------------------------------------------------------- + // navPath ↔ contentPath consistency (kubestellar/docs#7080). The + // sidebar for a project is read from `navPath`, and its entries are + // paths relative to `contentPath`, so the nav.yaml must sit at the + // root of the content tree it describes — and must exist, because + // buildPageMap() throws (a build error) when it is missing. + // ----------------------------------------------------------------- + it.each(projectEntries)( + '%s: navPath is /nav.yaml and the file exists', + (_key, project) => { + expect(project.navPath).toBe(`${project.contentPath}/nav.yaml`) + expect(existsSync(join(process.cwd(), project.navPath))).toBe(true) + }, + ) + // ----------------------------------------------------------------- // Every version flagged as isDev must live on `main`. `isDev` is // what the badge uses to render the "dev" tag; a dev version that diff --git a/src/app/docs/page-map.ts b/src/app/docs/page-map.ts index 2ba9d12850..072284240b 100644 --- a/src/app/docs/page-map.ts +++ b/src/app/docs/page-map.ts @@ -2,6 +2,15 @@ import { normalizePageMap } from 'nextra/page-map' import fs from 'fs' import path from 'path' import { PROJECTS, type ProjectId } from '@/config/versions' +import { + GENERAL_SECTIONS, + NAV_FILE_NAME, + isGeneralSectionFile, + loadNavFile, + navItemTouchesGeneralSection, + type NavItem, + type NavSection, +} from '@/lib/nav' // Local docs path - docs are now in this repository export const docsContentPath = path.join(process.cwd(), 'docs', 'content') @@ -67,427 +76,25 @@ function getAllDocFiles(dir: string, baseDir: string = dir): string[] { return files } -// Navigation structure based on mkdocs.yml -type NavItem = { [key: string]: string | NavItem[] | NavItem } | string - -// A2A Navigation Structure -const NAV_STRUCTURE_A2A: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Overview', - items: [ - { 'Introduction': 'intro.md' }, - ] - }, - { - title: 'Getting Started', - items: [ - { 'Quick Start': 'getting-started/quick-start.md' }, - { 'Installation': 'getting-started/installation.md' }, - { 'Guide Overview': 'getting-started/index.md' }, - ] - }, - { - title: 'Reference', - items: [ - { 'CLI Reference': 'cli-reference.md' }, - { 'Troubleshooting': 'troubleshooting.md' }, - ] - }, - { - title: 'Contribute to A2A', - items: [ - { 'Contribute to A2A': 'CONTRIBUTING.md' }, - ] - } -] - -// Multi Plugin Navigation Structure -const NAV_STRUCTURE_MULTI_PLUGIN: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Overview', - items: [ - { 'Introduction': 'readme.md' }, - { 'Architecture': 'architecture_guide.md' }, - ] - }, - { - title: 'Getting Started', - items: [ - { 'Installation': 'installation_guide.md' }, - { 'Installation (Windows)': 'installation_guide_windows.md' }, - { 'Usage Guide': 'usage_guide.md' }, - ] - }, - { - title: 'Reference', - items: [ - { 'API Reference': 'api_reference.md' }, - ] - }, - { - title: 'Development', - items: [ - { 'Development Guide': 'development_guide.md' }, - ] - } -] - -// KubeFlex Navigation Structure -const NAV_STRUCTURE_KUBEFLEX: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Overview', - items: [ - { 'Introduction': 'readme.md' }, - { 'Architecture': 'architecture.md' }, - { 'Multi-Tenancy': 'multi-tenancy.md' }, - ] - }, - { - title: 'Getting Started', - items: [ - { 'Quick Start': 'quickstart.md' }, - { 'User Guide': 'users.md' }, - ] - }, - { - title: 'Development', - items: [ - { 'Debugging': 'debugging.md' }, - { 'Code Generation': 'code-generation.md' }, - { 'PostgreSQL Architecture': 'postgresql-architecture-decision.md' }, - ] - }, - { - title: 'Community', - items: [ - { 'Contributors': 'contributors.md' }, - ] - } -] - -// kubestellar-mcp Navigation Structure -const NAV_STRUCTURE_KUBESTELLAR_MCP: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Overview', - items: [ - { 'Introduction': 'overview/intro.md' }, - ] - }, - { - title: 'Setup', - items: [ - { 'Homebrew Installation': 'setup/homebrew.md' }, - ] - }, - { - title: 'Architecture', - items: [ - { 'Architecture Overview': 'architecture/overview.md' }, - { 'Local Development': 'architecture/local-development.md' }, - { 'Request and Response Lifecycle': 'architecture/request-response-lifecycle.md' }, - { 'Adding a New Tool': 'architecture/adding-a-tool.md' }, - { 'Testing': 'architecture/testing.md' }, - ] - }, - { - title: 'Commands', - items: [ - { 'App Logs': 'commands/app-logs.md' }, - { 'App Status': 'commands/app-status.md' }, - { 'Delete': 'commands/delete.md' }, - { 'Deploy': 'commands/deploy.md' }, - { 'GitOps Drift': 'commands/gitops-drift.md' }, - { 'GitOps Sync': 'commands/gitops-sync.md' }, - { 'Helm Install': 'commands/helm-install.md' }, - { 'Helm Rollback': 'commands/helm-rollback.md' }, - { 'Helm Uninstall': 'commands/helm-uninstall.md' }, - { 'Kustomize': 'commands/kustomize.md' }, - { 'Label': 'commands/label.md' }, - ] - } -] - -// Console Navigation Structure -const NAV_STRUCTURE_CONSOLE: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Overview', - items: [ - { 'Introduction': 'readme.md' }, - { 'Quick Start': 'quickstart.md' }, - { 'Console Overview': 'console-overview.md' }, - { 'Architecture': 'architecture.md' }, - { 'Architecture Diagram': '_architecture-diagram.md' }, - { 'Installation': 'installation.md' }, - { 'Cluster Registration': 'cluster-registration.md' }, - { 'Demo Mode': 'demo-mode.md' }, - { 'Configuration': 'configuration.md' }, - { 'Updates and Releases': 'console-updates.md' }, - ] - }, - { - title: 'Setup', - items: [ - { 'Local Setup Guide': 'local-setup.md' }, - { 'Local Deployment': 'local-deployment.md' }, - { 'Deploy & Orchestrate': 'deploy.md' }, - { 'vCluster Setup': 'vcluster-setup.md' }, - ] - }, - { - title: 'Features', - items: [ - { 'Console Features': 'console-features.md' }, - { 'Dashboards': 'dashboards.md' }, - { 'Cards': 'all-cards.md' }, - { 'Card Types': 'cards.md' }, - { 'Card Reference': 'console-cards.md' }, - { 'Stats Blocks': 'stats-blocks.md' }, - { 'Alerts': 'alerts.md' }, - { 'Cost Optimization Cards': 'cost-optimization.md' }, - { 'Drasi Reactive Pipeline Dashboard': 'drasi-dashboard.md' }, - { 'KServe Monitoring Card': 'kserve-monitoring.md' }, - { 'Feedback System': 'feedback.md' }, - ] - }, - { - title: 'AI Integration', - items: [ - { 'AI Features': 'ai-features.md' }, - { 'AI Missions Setup': 'ai-missions-setup.md' }, - { 'Kagenti LLM Provider Setup': 'kagenti-llm-provider-setup.md' }, - { 'Local LLM Strategy': 'local-llm-strategy.md' }, - { 'Agentic Quality Controls': 'agentic-quality.md' }, - ] - }, - { - title: 'Security', - items: [ - { 'Authentication & Sessions': 'authentication.md' }, - { 'Security Model': 'security-model.md' }, - { 'Persistence & State Management': 'persistence.md' }, - ] - }, - { - title: 'Enterprise', - items: [ - { 'Enterprise Compliance Portal': 'enterprise-compliance.md' }, - { 'Federation & Multi-Hub': 'federation.md' }, - ] - }, - { - title: 'Development', - items: [ - { 'Development Methodology': 'development.md' }, - ] - }, - { - title: 'ACMM', - items: [ - { 'ACMM Dashboard': 'acmm-dashboard.md' }, - ] - }, - { - title: 'Programs', - items: [ - { 'Marketplace': 'marketplace.md' }, - { 'Knowledge Base': 'knowledge-base.md' }, - { 'Rewards System': 'console-rewards.md' }, - ] - }, - { - title: 'Troubleshooting', - items: [ - { 'Troubleshooting': 'troubleshooting.md' }, - { 'Windows 11 Local Source Build Troubleshooting': 'windows-11-local-source-build-troubleshooting.md' }, - ] - } -] - -// KubeStellar Navigation Structure -const NAV_STRUCTURE_KUBESTELLAR: Array<{ title: string; items: NavItem[] }> = [ - - { - title: 'Overview', - items: [ - { 'Introduction': 'readme.md' }, - { 'Architecture': 'kubestellar/architecture.md' }, - { 'OCM Status Addon': 'kubestellar/ocm-status-addon-intro.md' }, - { 'Release Notes': 'kubestellar/release-notes.md' }, - { 'Roadmap': 'kubestellar/roadmap.md' } - ] - }, - { - title: 'Getting Started', - items: [ - { 'Quick Start': 'kubestellar/get-started.md' }, - { 'Console Quick Start': 'console/quickstart.md' }, - ] - }, - { - title: 'User Guide', - items: [ - { 'Guide Overview': 'kubestellar/user-guide-intro.md' }, - { 'Observability': 'kubestellar/observability.md' }, - { 'Getting Started from OCM': 'kubestellar/start-from-ocm.md' }, - { - 'General Setup': [ - { 'Overview': 'kubestellar/setup-overview.md' }, - { 'Setup Limitations': 'kubestellar/setup-limitations.md' }, - { 'Prerequisites': 'kubestellar/pre-reqs.md' }, - { - 'KubeFlex Hosting Cluster': [ - { 'Acquire Cluster for KubeFlex Hosting': 'kubestellar/acquire-hosting-cluster.md' }, - { 'Initialize KubeFlex Hosting Cluster': 'kubestellar/init-hosting-cluster.md' } - ] - }, - { - 'Core Spaces': [ - { 'Inventory and Transport Spaces': 'kubestellar/its.md' }, - { 'Workload Description Spaces': 'kubestellar/wds.md' } - ] - }, - { 'Core Helm Chart': 'kubestellar/core-chart.md' }, - { 'Argo CD Integration': 'kubestellar/core-chart-argocd.md' }, - { - 'Workload Execution Clusters': [ - { 'About Workload Execution Clusters': 'kubestellar/wec.md' }, - { 'Register a Workload Execution Cluster': 'kubestellar/wec-registration.md' } - ] - } - ] - }, - { - 'Usage': [ - { 'Usage Limitations': 'kubestellar/usage-limitations.md' }, - { - 'KubeStellar API': [ - { 'Overview': 'kubestellar/control.md' }, - { 'Binding': 'kubestellar/binding.md' }, - { 'Transforming Desired State': 'kubestellar/transforming.md' }, - { 'Combining Reported State': 'kubestellar/combined-status.md' }, - { 'Multi-WEC Aggregated Status': 'kubestellar/multi-wec-aggregated-status.md' } - ] - }, - { 'Authorization in WECs': 'kubestellar/authorization.md' }, - { 'Example Scenarios': 'kubestellar/example-scenarios.md' }, - { 'Deploy Helm Charts Through a WDS': 'kubestellar/helm-through-wds.md' }, - { - 'Third-party Integrations': [ - { 'ArgoCD to WDS': 'kubestellar/argo-to-wds1.md' }, - { 'Claude Code': 'kubestellar/claude-code.md' } - ] - }, - { 'Troubleshooting': 'kubestellar/troubleshooting.md' }, - { - 'Known Issues': [ - { 'Overview': 'kubestellar/known-issues.md' }, - { 'Hidden State in Kubeconfig': 'kubestellar/knownissue-kflex-extension.md' }, - { 'Kind Needs OS Reconfig': 'kubestellar/knownissue-kind-config.md' }, - { 'Helm Chart Auth Failure': 'kubestellar/knownissue-helm-ghcr.md' }, - { 'Missing CombinedStatus Results': 'kubestellar/knownissue-collector-miss.md' }, - { 'Kind Host Configuration': 'kubestellar/installation-errors.md' }, - { 'Insufficient CPU': 'kubestellar/knownissue-cpu-insufficient-for-its1.md' } - ] - } - ] - }, - { - 'UI (Deprecated → Console)': [ - { 'Overview': 'ui-docs/ui-overview.md' }, - { 'WECS Remote Monitoring': 'ui-docs/wecs-remote-monitoring.md' }, - { 'ITS cluster management': 'ui-docs/its-cluster-management.md' } - ] - }, - { 'Teardown': 'kubestellar/teardown.md' } - ] - } -] - -const NAV_STRUCTURE_CONTRIBUTING: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Contributing', - items: [ - { 'Overview': 'contributing/contribute.md' }, - { 'Code of Conduct': 'contributing/coc-inc.md' }, - { 'Contributing to Code': 'contributing/CONTRIBUTINGKS.md' }, - { 'Contributing to Docs/Website': [ - {'Docs Structure': 'contributing/documentation/docs-structure-inc.md'} , - {'Simple Changes' : 'contributing/documentation/simple-docs-inc.md'}, - {'Version Management' : 'contributing/documentation/docs-version-inc.md'}, - {'Detailed Contribution Guide': 'contributing/documentation/contributing-inc.md' }, - {'Style Guide': 'contributing/documentation/docs-styleguide.md' } - ]}, - { 'Contributor Ladder': 'contributing/contributor_ladder.md' }, - { 'License': 'contributing/license-inc.md' }, - { 'Governance': 'contributing/governance-inc.md' }, - { 'Onboarding': 'contributing/onboarding-inc.md' }, - { - 'CI/CD': [ - { 'GitHub Actions': 'contributing/operations/github-actions.md' }, - { 'Demoting Component Repo Docs': 'contributing/operations/demote-component-docs.md' } - ] - }, - { - 'Security': [ - { 'Policy': 'contributing/security/security-inc.md' }, - { 'Contacts': 'contributing/security/security_contacts-inc.md' } - ] - }, - { 'Testing': 'kubestellar/testing.md' }, - { 'Packaging': 'kubestellar/packaging.md' }, - { 'Release Process': 'kubestellar/release.md' }, - { 'Release Testing': 'kubestellar/release-testing.md' }, - { 'Sign-off': 'kubestellar/pr-signoff.md' } - ] - } -] - -const NAV_STRUCTURE_COMMUNITY: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'Community', - items: [ - { 'Get Involved': 'community/index.md' }, - { 'Videos and Demos': 'community/videos.md' }, - { 'Community Meetings': 'community/meetings.md' }, - ] - } -] - -const NAV_STRUCTURE_NEWS: Array<{ title: string; items: NavItem[] }> = [ - { - title: 'News', - items: [ - { 'Latest News': 'news/index.md' }, - { 'Marketplace & KB Launch': 'news/marketplace-and-kb-launch.md' }, - { 'KubeStellar Console Announcement': 'news/kubestellar-console-announcement.md' }, - { 'Reviews and Testimonials': 'news/reviews.md'} - ] - } -] - -// Get navigation structure for a project. -// -// Data-driven from NAV_STRUCTURE_BY_PROJECT: Record so a new -// value added to the ProjectId union fails to compile until the maintainer -// supplies a nav structure, instead of silently falling through to -// NAV_STRUCTURE_KUBESTELLAR via a `default:` arm. See kubestellar/docs#7037. -const NAV_STRUCTURE_BY_PROJECT: Record< - ProjectId, - Array<{ title: string; items: NavItem[] }> -> = { - kubestellar: NAV_STRUCTURE_KUBESTELLAR, - a2a: NAV_STRUCTURE_A2A, - kubeflex: NAV_STRUCTURE_KUBEFLEX, - 'multi-plugin': NAV_STRUCTURE_MULTI_PLUGIN, - 'kubestellar-mcp': NAV_STRUCTURE_KUBESTELLAR_MCP, - console: NAV_STRUCTURE_CONSOLE, -} - -function getNavStructure(projectId: ProjectId): Array<{ title: string; items: NavItem[] }> { - const baseStructure = NAV_STRUCTURE_BY_PROJECT[projectId] - - // Add general sections to all projects - return [...baseStructure, ...NAV_STRUCTURE_CONTRIBUTING, ...NAV_STRUCTURE_COMMUNITY, ...NAV_STRUCTURE_NEWS] +// Sidebar navigation is data: see src/lib/nav.ts for the nav.yaml schema and +// loader. Re-exported so existing importers of page-map keep working. +export { GENERAL_SECTIONS, NAV_FILE_NAME, isGeneralSectionFile, loadNavFile } from '@/lib/nav' +export type { NavItem, NavSection } from '@/lib/nav' + +// Root-level pages of docs/content/ that every project can resolve. +const ROOT_SHARED_PAGES = ['intro.md', 'legacy-components.md', 'what-is-console.md'] as const + +type ResolvedNavSection = NavSection & { general: boolean } + +// Get navigation structure for a project: its own nav.yaml (from +// PROJECTS[projectId].navPath) followed by every GENERAL_SECTIONS nav. +export function getNavStructure(projectId: ProjectId): ResolvedNavSection[] { + const projectSections = loadNavFile(path.join(process.cwd(), PROJECTS[projectId].navPath)) + .map(section => ({ ...section, general: false })) + const generalSections = GENERAL_SECTIONS.flatMap(section => + loadNavFile(path.join(docsContentPath, section, NAV_FILE_NAME)).map(s => ({ ...s, general: true })) + ) + return [...projectSections, ...generalSections] } export function buildPageMap(projectId: ProjectId = 'kubestellar') { @@ -500,8 +107,7 @@ export function buildPageMap(projectId: ProjectId = 'kubestellar') { if (projectId !== 'kubestellar') { // Add general sections files and root-level pages from main KubeStellar directory const generalFiles = getAllDocFiles(docsContentPath).filter(f => - f.startsWith('contributing/') || f.startsWith('community/') || f.startsWith('news/') || - f === 'intro.md' || f === 'legacy-components.md' || f === 'what-is-console.md' + isGeneralSectionFile(f) || (ROOT_SHARED_PAGES as readonly string[]).includes(f) ) allDocFiles = [...allDocFiles, ...generalFiles] } @@ -520,8 +126,7 @@ export function buildPageMap(projectId: ProjectId = 'kubestellar') { processedFiles.add(item) const baseName = item.replace(/\.(md|mdx)$/i, '').split('/').pop()! // Use /docs path for general sections, project path for everything else - const isGeneralSection = item.startsWith('contributing/') || item.startsWith('community/') || item.startsWith('news/') - const basePathForRoute = isGeneralSection ? 'docs' : projectBasePath + const basePathForRoute = isGeneralSectionFile(item) ? 'docs' : projectBasePath const route = `/${basePathForRoute}/${parentSlug}/${baseName}` routeMap[`${parentSlug}/${baseName}`] = item nodes.push({ kind: 'MdxPage', name: pretty(baseName), route }) @@ -543,8 +148,7 @@ export function buildPageMap(projectId: ProjectId = 'kubestellar') { // const baseName = value.replace(/\.(md|mdx)$/i, '').split('/').pop()! const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') // Use /docs path for general sections, project path for everything else - const isGeneralSection = value.startsWith('contributing/') || value.startsWith('community/') || value.startsWith('news/') - const basePathForRoute = isGeneralSection ? 'docs' : projectBasePath + const basePathForRoute = isGeneralSectionFile(value) ? 'docs' : projectBasePath const route = `/${basePathForRoute}/${parentSlug ? parentSlug + '/' : ''}${slug}` routeMap[`${parentSlug ? parentSlug + '/' : ''}${slug}`] = value nodes.push({ kind: 'MdxPage', name: title, route }) @@ -556,24 +160,9 @@ export function buildPageMap(projectId: ProjectId = 'kubestellar') { const newParentSlug = parentSlug ? `${parentSlug}/${slug}` : slug const children = buildNavNodes(value, newParentSlug) if (children.length > 0) { - // Use /docs path for general sections, project path for everything else - // Check both direct string entries and nested values in objects - const isGeneralSection = Array.isArray(value) && - value.some(v => { - if (typeof v === 'string') { - return v.startsWith('contributing/') || v.startsWith('community/') || v.startsWith('news/') - } - // For object entries, check if any value starts with general section path - if (typeof v === 'object' && v !== null) { - const objValues = Object.values(v); - return objValues.some(val => - typeof val === 'string' && - (val.startsWith('contributing/') || val.startsWith('community/') || val.startsWith('news/')) - ); - } - return false; - }) - const basePathForRoute = isGeneralSection ? 'docs' : projectBasePath + // Use /docs path for general sections, project path for everything else. + // A folder is general when any direct child points into a general section. + const basePathForRoute = value.some(navItemTouchesGeneralSection) ? 'docs' : projectBasePath nodes.push({ kind: 'Folder', name: title, @@ -593,6 +182,14 @@ export function buildPageMap(projectId: ProjectId = 'kubestellar') { return nodes } + // Top-level section titles that anchor under /docs/ instead of the + // project base. Derived from the general-section navs (Contributing, + // Community, News) rather than hard-coded. Matching by TITLE — not by which + // nav a section came from — is deliberate: a project section that reuses a + // shared title (kubeflex's own "Community") is anchored under /docs/ too, + // which page-map-routes.test.ts pins as the sidebar's existing behaviour. + const generalSectionTitles = new Set(navStructure.filter(c => c.general).map(c => c.title)) + // Build navigation from navStructure (project-specific) for (const category of navStructure) { const categorySlug = category.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') @@ -600,8 +197,7 @@ export function buildPageMap(projectId: ProjectId = 'kubestellar') { if (children.length > 0) { // Use /docs path for general sections, project path for project-specific sections - const isGeneralSection = ['Contributing', 'Community', 'News'].includes(category.title) - const basePath = isGeneralSection ? 'docs' : projectBasePath + const basePath = generalSectionTitles.has(category.title) ? 'docs' : projectBasePath const folderNode: FolderNode = { kind: 'Folder', diff --git a/src/config/versions/lookup.ts b/src/config/versions/lookup.ts index e570f5e055..17ee113b92 100644 --- a/src/config/versions/lookup.ts +++ b/src/config/versions/lookup.ts @@ -15,6 +15,7 @@ export const PROJECTS: Record = { basePath: "", currentVersion: "0.30.0", contentPath: "docs/content", + navPath: "docs/content/nav.yaml", versions: KUBESTELLAR_VERSIONS, pathPrefixes: [], }, @@ -24,6 +25,7 @@ export const PROJECTS: Record = { basePath: "a2a", currentVersion: "0.1.0", contentPath: "docs/content/a2a", + navPath: "docs/content/a2a/nav.yaml", versions: A2A_VERSIONS, pathPrefixes: ["/docs/a2a"], }, @@ -33,6 +35,7 @@ export const PROJECTS: Record = { basePath: "kubeflex", currentVersion: "0.9.3", contentPath: "docs/content/kubeflex", + navPath: "docs/content/kubeflex/nav.yaml", versions: KUBEFLEX_VERSIONS, pathPrefixes: ["/docs/kubeflex"], }, @@ -42,6 +45,7 @@ export const PROJECTS: Record = { basePath: "multi-plugin", currentVersion: "0.1.0", contentPath: "docs/content/multi-plugin", + navPath: "docs/content/multi-plugin/nav.yaml", versions: MULTI_PLUGIN_VERSIONS, pathPrefixes: ["/docs/multi-plugin"], }, @@ -51,6 +55,7 @@ export const PROJECTS: Record = { basePath: "kubestellar-mcp", currentVersion: "0.9.15", contentPath: "docs/content/kubestellar-mcp", + navPath: "docs/content/kubestellar-mcp/nav.yaml", versions: KUBESTELLAR_MCP_VERSIONS, pathPrefixes: ["/docs/kubestellar-mcp", "/docs/related-projects/kubestellar-mcp"], }, @@ -60,6 +65,7 @@ export const PROJECTS: Record = { basePath: "console", currentVersion: "0.3.42", contentPath: "docs/content/console", + navPath: "docs/content/console/nav.yaml", versions: CONSOLE_VERSIONS, pathPrefixes: ["/docs/console"], }, diff --git a/src/config/versions/types.ts b/src/config/versions/types.ts index 7e2c6ee20b..3ef94d2d38 100644 --- a/src/config/versions/types.ts +++ b/src/config/versions/types.ts @@ -17,6 +17,13 @@ export interface ProjectConfig { basePath: string // '' for kubestellar, 'a2a' for a2a, etc. currentVersion: string contentPath: string + /** + * Repo-relative path of the YAML file that defines this project's sidebar + * navigation (loaded by src/app/docs/page-map.ts). Entries in that file are + * paths relative to `contentPath`. Mandatory, like contentPath/basePath, so + * adding a ProjectId without a nav fails to compile. See kubestellar/docs#7080. + */ + navPath: string versions: Record /** * URL path prefixes that route to this project via `getProjectFromPath`. diff --git a/src/lib/nav.ts b/src/lib/nav.ts new file mode 100644 index 0000000000..69cadda102 --- /dev/null +++ b/src/lib/nav.ts @@ -0,0 +1,128 @@ +import fs from 'fs' +import path from 'path' +import { load as parseYaml } from 'js-yaml' + +// --------------------------------------------------------------------------- +// Sidebar navigation data files +// +// Navigation is DATA, not code. Each project's sidebar lives in a nav.yaml next +// to the content it describes (PROJECTS[projectId].navPath, e.g. +// docs/content/console/nav.yaml), and the cross-project sections live in +// docs/content/
/nav.yaml (see GENERAL_SECTIONS). The schema mirrors an +// mkdocs.yml `nav:` block: +// +// - title: Section +// items: +// - Page Title: relative/path.md +// - Folder Title: +// - Child Title: relative/path.md +// +// Files are loaded and validated by loadNavFile() when the page map is built, +// so a missing or malformed nav is a build error rather than an `undefined` +// spread at runtime. This module is deliberately free of Next.js / nextra +// imports so scripts/check-internal-links.ts can reuse it under plain tsx. +// See kubestellar/docs#7080. +// --------------------------------------------------------------------------- + +export type NavItem = { [key: string]: string | NavItem[] | NavItem } | string +export type NavSection = { title: string; items: NavItem[] } + +// Cross-project sections appended (in this order) to every project's nav. +// Each is a directory under docs/content/ whose pages route under /docs// +// regardless of the project being rendered, and whose sidebar section is +// defined in docs/content//nav.yaml. +export const GENERAL_SECTIONS = ['contributing', 'community', 'news'] as const + +export const NAV_FILE_NAME = 'nav.yaml' + +// True when a content-relative file path belongs to one of GENERAL_SECTIONS +// (and therefore routes under /docs/… instead of /docs//…). +export function isGeneralSectionFile(file: string): boolean { + return GENERAL_SECTIONS.some(section => file.startsWith(`${section}/`)) +} + +// True when a nav item (string path, or {title: path|children} object) points +// at — or contains a direct child pointing at — a general-section file. +export function navItemTouchesGeneralSection(item: NavItem): boolean { + if (typeof item === 'string') return isGeneralSectionFile(item) + return Object.values(item).some(v => typeof v === 'string' && isGeneralSectionFile(v)) +} + +export class NavFileError extends Error { + constructor(file: string, message: string) { + super(`Invalid sidebar nav file ${file}: ${message}`) + this.name = 'NavFileError' + } +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function validateNavItem(file: string, item: unknown, where: string): NavItem { + if (typeof item === 'string') { + if (item.length === 0) throw new NavFileError(file, `${where}: empty path`) + return item + } + if (!isPlainObject(item)) { + throw new NavFileError(file, `${where}: expected "Title: path" or "Title: [children]", got ${JSON.stringify(item)}`) + } + const keys = Object.keys(item) + if (keys.length !== 1) { + throw new NavFileError(file, `${where}: each item must have exactly one "Title" key, got ${keys.length} (${keys.join(', ')})`) + } + const title = keys[0] + const value = item[title] + if (typeof value === 'string') { + if (value.length === 0) throw new NavFileError(file, `${where} "${title}": empty path`) + return { [title]: value } + } + if (Array.isArray(value)) { + return { [title]: value.map((child, i) => validateNavItem(file, child, `${where} "${title}"[${i}]`)) } + } + throw new NavFileError(file, `${where} "${title}": value must be a path string or a list of items, got ${JSON.stringify(value)}`) +} + +// Validate already-parsed YAML/JSON against the nav schema. `file` is only +// used to label errors. +export function validateNavSections(file: string, parsed: unknown): NavSection[] { + if (!Array.isArray(parsed)) { + throw new NavFileError(file, 'top level must be a list of { title, items } sections') + } + return parsed.map((section, i) => { + const where = `section[${i}]` + if (!isPlainObject(section)) throw new NavFileError(file, `${where}: expected { title, items }`) + const { title, items } = section + if (typeof title !== 'string' || title.length === 0) throw new NavFileError(file, `${where}: "title" must be a non-empty string`) + if (!Array.isArray(items)) throw new NavFileError(file, `${where} "${title}": "items" must be a list`) + return { title, items: items.map((item, j) => validateNavItem(file, item, `${where} "${title}".items[${j}]`)) } + }) +} + +// Parse and validate nav YAML source text. `file` is only used to label errors. +export function parseNavYaml(file: string, source: string): NavSection[] { + let parsed: unknown + try { + parsed = parseYaml(source) + } catch (err) { + throw new NavFileError(file, `YAML parse error: ${err instanceof Error ? err.message : String(err)}`) + } + return validateNavSections(file, parsed) +} + +// Read and validate a nav.yaml from disk. Throws NavFileError (naming the +// offending file and location) on a missing or unreadable file, YAML syntax +// error, or schema violation. +export function loadNavFile(absPath: string): NavSection[] { + const rel = path.relative(process.cwd(), absPath).replace(/\\/g, '/') + if (!fs.existsSync(absPath)) { + throw new NavFileError(rel, 'file not found') + } + let source: string + try { + source = fs.readFileSync(absPath, 'utf8') + } catch (err) { + throw new NavFileError(rel, `read error: ${err instanceof Error ? err.message : String(err)}`) + } + return parseNavYaml(rel, source) +}