docs: own public documentation metadata (#1713)
This commit is contained in:
@@ -14,7 +14,8 @@ Use this skill to decide whether public docs need updates and to make those upda
|
|||||||
- Implementation plans stay under `docs/plans/**`.
|
- Implementation plans stay under `docs/plans/**`.
|
||||||
- Architecture decisions stay under `docs/decisions/**`.
|
- Architecture decisions stay under `docs/decisions/**`.
|
||||||
- Raw supporting notes can remain under `docs/**` outside `docs/public/**`, but do not publish them unless rewritten for users.
|
- Raw supporting notes can remain under `docs/**` outside `docs/public/**`, but do not publish them unless rewritten for users.
|
||||||
- The landing/docs website consumes public docs through its manifest and generated content. Do not hand-edit generated website docs.
|
- `docs/public/meta.json` owns published-page order and navigation groups. Page paths own routes, and page frontmatter owns titles and descriptions.
|
||||||
|
- The landing/docs website generates its content from this directory. Do not hand-edit generated files in the landing repository.
|
||||||
|
|
||||||
## When Docs Need Updates
|
## When Docs Need Updates
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ Skip public docs when the change is:
|
|||||||
2. Search `docs/public/**` first for affected terms and commands.
|
2. Search `docs/public/**` first for affected terms and commands.
|
||||||
3. If public docs exist, update them with the same PR as the behavior change.
|
3. If public docs exist, update them with the same PR as the behavior change.
|
||||||
4. If no public docs exist but the behavior is user-facing, add or propose the smallest useful public page/section.
|
4. If no public docs exist but the behavior is user-facing, add or propose the smallest useful public page/section.
|
||||||
|
When adding a page, include `title` and `description` frontmatter and list its page slug or path without the `.md` extension in `docs/public/meta.json` exactly once, for example `cli`. See `docs/public/README.md`.
|
||||||
5. If the change only updates implementation intent or architectural history, update specs/plans/ADRs instead.
|
5. If the change only updates implementation intent or architectural history, update specs/plans/ADRs instead.
|
||||||
6. Keep public docs task-oriented: prerequisites, commands, expected result, troubleshooting, and links to reference.
|
6. Keep public docs task-oriented: prerequisites, commands, expected result, troubleshooting, and links to reference.
|
||||||
7. Preserve internal links inside `docs/public/**` where possible. Link to source-only raw docs only when the raw note is intentionally not published.
|
7. Preserve internal links inside `docs/public/**` where possible. Link to source-only raw docs only when the raw note is intentionally not published.
|
||||||
@@ -51,13 +53,16 @@ Run the checks relevant to your change:
|
|||||||
```bash
|
```bash
|
||||||
# Replace SEARCH_TERM with the command, config key, or terminology that changed.
|
# Replace SEARCH_TERM with the command, config key, or terminology that changed.
|
||||||
rg -n "SEARCH_TERM" docs/public docs/specs docs/decisions
|
rg -n "SEARCH_TERM" docs/public docs/specs docs/decisions
|
||||||
|
node --test scripts/validate-public-docs.test.mjs
|
||||||
|
node scripts/validate-public-docs.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
For website docs publishing changes, also run from the landing repo:
|
For website docs publishing changes, also run from the landing repo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
pnpm --filter @kandev/docs fetch-docs
|
pnpm --filter @kandev/docs fetch-docs
|
||||||
pnpm exec vitest run apps/docs/lib/docs-processing.test.ts
|
pnpm exec vitest run apps/docs/lib/docs-processing.test.ts apps/docs/lib/public-docs.test.ts
|
||||||
pnpm --filter @kandev/docs build
|
pnpm --filter @kandev/docs build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Published Docs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "docs/public/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "docs/public/**"
|
||||||
|
- "scripts/validate-public-docs.mjs"
|
||||||
|
- "scripts/validate-public-docs.test.mjs"
|
||||||
|
- ".github/workflows/notify-docs.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate public docs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Test public docs validator
|
||||||
|
run: node --test scripts/validate-public-docs.test.mjs
|
||||||
|
|
||||||
|
- name: Validate public docs
|
||||||
|
run: node scripts/validate-public-docs.mjs
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
name: Trigger Cloudflare Pages rebuild
|
||||||
|
needs: validate
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 4
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger Cloudflare Pages deploy hook
|
||||||
|
env:
|
||||||
|
DEPLOY_HOOK_URL: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_HOOK_KANDEV_AI }}
|
||||||
|
run: |
|
||||||
|
if [[ -z "$DEPLOY_HOOK_URL" ]]; then
|
||||||
|
echo "::error::CLOUDFLARE_PAGES_DEPLOY_HOOK_KANDEV_AI is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--max-time 30 \
|
||||||
|
--retry 3 \
|
||||||
|
--retry-all-errors \
|
||||||
|
--retry-max-time 90 \
|
||||||
|
--request POST \
|
||||||
|
"$DEPLOY_HOOK_URL"
|
||||||
+1
-1
@@ -14,7 +14,7 @@ You must understand the code you submit. You're welcome to use AI tools to help
|
|||||||
|
|
||||||
1. **Fork and branch.** Create a feature branch from `main`.
|
1. **Fork and branch.** Create a feature branch from `main`.
|
||||||
2. **Keep PRs focused.** One logical change per PR. Small PRs get reviewed faster.
|
2. **Keep PRs focused.** One logical change per PR. Small PRs get reviewed faster.
|
||||||
3. **Update public docs when behavior changes.** User-facing docs live in `docs/public/**`. If your change affects CLI commands, config keys, install/deploy flows, workflows, executors, APIs, screenshots, or user-facing terminology, update the relevant public docs in the same PR.
|
3. **Update public docs when behavior changes.** User-facing docs live in `docs/public/**`. If your change affects CLI commands, config keys, install/deploy flows, workflows, executors, APIs, screenshots, or user-facing terminology, update the relevant public docs in the same PR. See the [public docs contribution guide](docs/public/README.md) when editing navigation or adding a page.
|
||||||
4. **Test your changes.** Run `make fmt` first, then `make typecheck test lint` before submitting. Manually verify that your feature works end-to-end, and add screenshots or recordings to the PR if it has a UI component. **If your change touches any UI files (anything under `apps/web/`), you must add or update Playwright e2e tests in `apps/web/e2e/` to prevent regressions.** Run them with `make test-e2e`. See [docs/test_e2e_web.md](docs/test_e2e_web.md) for patterns and fixtures.
|
4. **Test your changes.** Run `make fmt` first, then `make typecheck test lint` before submitting. Manually verify that your feature works end-to-end, and add screenshots or recordings to the PR if it has a UI component. **If your change touches any UI files (anything under `apps/web/`), you must add or update Playwright e2e tests in `apps/web/e2e/` to prevent regressions.** Run them with `make test-e2e`. See [docs/test_e2e_web.md](docs/test_e2e_web.md) for patterns and fixtures.
|
||||||
|
|
||||||
## Bug Reports
|
## Bug Reports
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ test-scripts:
|
|||||||
@python3 scripts/lint-harness-files.test.py
|
@python3 scripts/lint-harness-files.test.py
|
||||||
@bash scripts/release-desktop.test.sh
|
@bash scripts/release-desktop.test.sh
|
||||||
@node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs
|
@node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs
|
||||||
|
@node --test scripts/validate-public-docs.test.mjs
|
||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
test-e2e: build-backend build-web
|
test-e2e: build-backend build-web
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Contributing to the Public Docs
|
||||||
|
|
||||||
|
Files in this directory are the source for [kandev.ai/docs](https://kandev.ai/docs). A merged change under `docs/public/**` triggers a Cloudflare Pages rebuild automatically.
|
||||||
|
|
||||||
|
## Update an Existing Page
|
||||||
|
|
||||||
|
Edit the relevant Markdown file directly. Keep commands, configuration keys, API names, and screenshots aligned with the implementation in the same pull request.
|
||||||
|
|
||||||
|
Use relative links between published pages, for example `[CLI](cli.md)`. The website build rewrites those links to `/docs/...` routes. Links to repository files outside `docs/public` are rewritten to their GitHub source URLs.
|
||||||
|
|
||||||
|
## Add a Page
|
||||||
|
|
||||||
|
1. Create a lowercase, kebab-case Markdown file in this directory, such as `custom-executors.md`. Its filename becomes its route: `custom-executors.md` is published at `/docs/custom-executors`.
|
||||||
|
2. Start the file with `title` and `description` frontmatter, followed by one level-one heading:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Custom Executors"
|
||||||
|
description: "Configure a custom executor for Kandev tasks."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Custom Executors
|
||||||
|
|
||||||
|
Page content starts here.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the filename without its extension to `meta.json` exactly once. Its position controls sidebar order. Entries such as `---Execution---` create section labels.
|
||||||
|
4. Link the new page from related documentation where useful.
|
||||||
|
|
||||||
|
Pages omitted from `meta.json`, duplicate entries, unknown entries, and missing frontmatter fail validation.
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
Run the dependency-free checks from the Kandev repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test scripts/validate-public-docs.test.mjs
|
||||||
|
node scripts/validate-public-docs.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
To build the complete docs site, clone `kdlbs/landing` beside this repository and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../landing
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
KANDEV_DOCS_SOURCE_PATH=../kandev/docs pnpm --filter @kandev/docs build
|
||||||
|
```
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Add an Agent CLI"
|
||||||
|
description: "Add a new ACP adapter or agent CLI integration to Kandev."
|
||||||
|
---
|
||||||
|
|
||||||
# Adding a New Agent CLI Integration
|
# Adding a New Agent CLI Integration
|
||||||
|
|
||||||
This guide explains how to add support for a new coding agent CLI to Kandev.
|
This guide explains how to add support for a new coding agent CLI to Kandev.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "CLI"
|
||||||
|
description: "Install, start, and operate Kandev from the command line."
|
||||||
|
---
|
||||||
|
|
||||||
# Kandev CLI
|
# Kandev CLI
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Configuration"
|
||||||
|
description: "Configure Kandev workspaces, runtimes, settings, and environment behavior."
|
||||||
|
---
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Kandev's backend reads configuration from three sources, in this order of precedence (later sources override earlier ones):
|
Kandev's backend reads configuration from three sources, in this order of precedence (later sources override earlier ones):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Desktop App"
|
||||||
|
description: "Install and run the Tauri desktop app with the native Kandev runtime."
|
||||||
|
---
|
||||||
|
|
||||||
# Kandev Desktop App
|
# Kandev Desktop App
|
||||||
|
|
||||||
Kandev desktop is a Tauri app that starts the native Kandev runtime locally and shows the existing Kandev UI in a system WebView. It does not require Node.js at runtime. Node.js, pnpm, Rust, and the Tauri CLI are build-time tools only.
|
Kandev desktop is a Tauri app that starts the native Kandev runtime locally and shows the existing Kandev UI in a system WebView. It does not require Node.js at runtime. Node.js, pnpm, Rust, and the Tauri CLI are build-time tools only.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Docker"
|
||||||
|
description: "Run Kandev and agent execution environments with Docker."
|
||||||
|
---
|
||||||
|
|
||||||
# Docker Guide
|
# Docker Guide
|
||||||
|
|
||||||
Run Kandev in a Docker container. For Kubernetes deployment, see [k8s.md](k8s.md).
|
Run Kandev in a Docker container. For Kubernetes deployment, see [k8s.md](k8s.md).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Git Operations"
|
||||||
|
description: "Understand how Kandev manages repositories, worktrees, branches, and review surfaces."
|
||||||
|
---
|
||||||
|
|
||||||
# Git Operations for Worktrees
|
# Git Operations for Worktrees
|
||||||
|
|
||||||
This document describes the architecture for Git operations (Pull, Push, Rebase, Merge, and Abort) on worktrees in Kandev.
|
This document describes the architecture for Git operations (Pull, Push, Rebase, Merge, and Abort) on worktrees in Kandev.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Overview"
|
||||||
|
description: "Start building with Kandev agentic development workflows."
|
||||||
|
---
|
||||||
|
|
||||||
# Kandev Feature Guide
|
# Kandev Feature Guide
|
||||||
|
|
||||||
This page expands the short feature list in the README without turning the README into a catalog.
|
This page expands the short feature list in the README without turning the README into a catalog.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Kubernetes"
|
||||||
|
description: "Deploy Kandev in Kubernetes environments."
|
||||||
|
---
|
||||||
|
|
||||||
# Kubernetes Deployment Guide
|
# Kubernetes Deployment Guide
|
||||||
|
|
||||||
This guide covers building the Kandev Docker image and deploying it to a Kubernetes cluster.
|
This guide covers building the Kandev Docker image and deploying it to a Kubernetes cluster.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"title": "Kandev Docs",
|
||||||
|
"pages": [
|
||||||
|
"---Start---",
|
||||||
|
"index",
|
||||||
|
"cli",
|
||||||
|
"configuration",
|
||||||
|
"desktop-app",
|
||||||
|
"---Execution---",
|
||||||
|
"docker",
|
||||||
|
"k8s",
|
||||||
|
"run-as-a-service",
|
||||||
|
"remote-cloud-environment",
|
||||||
|
"windows-support",
|
||||||
|
"---Workflows---",
|
||||||
|
"workflow-tips",
|
||||||
|
"workflow-import-export",
|
||||||
|
"git-operations",
|
||||||
|
"add-agent-cli",
|
||||||
|
"---Reference---",
|
||||||
|
"websocket-api"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Remote Cloud Environment"
|
||||||
|
description: "Use remote execution environments for agent work."
|
||||||
|
---
|
||||||
|
|
||||||
# Remote Cloud Environment Instructions
|
# Remote Cloud Environment Instructions
|
||||||
|
|
||||||
Setup notes and caveats for developing Kandev in ephemeral cloud VMs (Cursor Cloud, Codex, GitHub Codespaces, or similar sandboxed environments).
|
Setup notes and caveats for developing Kandev in ephemeral cloud VMs (Cursor Cloud, Codex, GitHub Codespaces, or similar sandboxed environments).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Run as a Service"
|
||||||
|
description: "Run Kandev as a persistent service on your own machine or infrastructure."
|
||||||
|
---
|
||||||
|
|
||||||
# Run Kandev as a Service
|
# Run Kandev as a Service
|
||||||
|
|
||||||
Install Kandev as an OS-managed service (systemd on Linux, launchd on macOS) so it auto-starts and stays running. User-mode services installed by `kandev service install` can self-update from the System → Updates page. Non-service installs and `--system` services still update manually: `npm i -g kandev@latest` or `brew upgrade kandev`, then re-run `kandev service install`.
|
Install Kandev as an OS-managed service (systemd on Linux, launchd on macOS) so it auto-starts and stays running. User-mode services installed by `kandev service install` can self-update from the System → Updates page. Non-service installs and `--system` services still update manually: `npm i -g kandev@latest` or `brew upgrade kandev`, then re-run `kandev service install`.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "WebSocket API"
|
||||||
|
description: "Reference for Kandev WebSocket messages and API behavior."
|
||||||
|
---
|
||||||
|
|
||||||
# Kandev WebSocket API
|
# Kandev WebSocket API
|
||||||
|
|
||||||
This page documents the WebSocket envelope, current domain model, and the request actions used by the main task and agent workflow. It is not a substitute for the Go request types: payloads evolve with the application.
|
This page documents the WebSocket envelope, current domain model, and the request actions used by the main task and agent workflow. It is not a substitute for the Go request types: payloads evolve with the application.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Windows Support"
|
||||||
|
description: "Run Kandev on Windows through native and WSL workflows."
|
||||||
|
---
|
||||||
|
|
||||||
# Windows Support (WSL)
|
# Windows Support (WSL)
|
||||||
|
|
||||||
Kandev runs on Windows through WSL (Windows Subsystem for Linux).
|
Kandev runs on Windows through WSL (Windows Subsystem for Linux).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Workflow Import and Export"
|
||||||
|
description: "Move workflows between workspaces or Kandev installs with portable YAML."
|
||||||
|
---
|
||||||
|
|
||||||
# Workflow Import / Export — Portable YAML Format
|
# Workflow Import / Export — Portable YAML Format
|
||||||
|
|
||||||
Kandev workflows can be exported to a portable YAML file and imported into
|
Kandev workflows can be exported to a portable YAML file and imported into
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Workflow Tips"
|
||||||
|
description: "Practical guidance for designing and operating Kandev workflows."
|
||||||
|
---
|
||||||
|
|
||||||
# Workflows
|
# Workflows
|
||||||
|
|
||||||
Kandev ships with five workflow templates. Each defines a sequence of steps with automated transitions - agents start, stop, and move between steps based on events like entering a step, sending a message, or an agent completing its turn.
|
Kandev ships with five workflow templates. Each defines a sequence of steps with automated transitions - agents start, stop, and move between steps based on events like entering a step, sending a message, or an agent completing its turn.
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that every published Markdown page has frontmatter and appears
|
||||||
|
* exactly once in the public navigation metadata.
|
||||||
|
*
|
||||||
|
* @param {string} [docsDir] Directory containing published docs and meta.json.
|
||||||
|
* @returns {Promise<{pageCount: number}>} Number of validated published pages.
|
||||||
|
*/
|
||||||
|
export async function validatePublicDocs(
|
||||||
|
docsDir = path.join(repoRoot, "docs/public"),
|
||||||
|
) {
|
||||||
|
const meta = await readMeta(docsDir);
|
||||||
|
const files = await collectMarkdownFiles(docsDir);
|
||||||
|
const pagesBySlug = new Map();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (path.posix.basename(file).toLowerCase() === "readme.md") continue;
|
||||||
|
|
||||||
|
const markdown = await fs.readFile(path.join(docsDir, file), "utf8");
|
||||||
|
assertFrontmatter(file, markdown);
|
||||||
|
|
||||||
|
const slug = file.replace(/\.mdx?$/, "").replace(/\/index$/, "");
|
||||||
|
const existing = pagesBySlug.get(slug);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(
|
||||||
|
`multiple published files resolve to slug ${slug}: ${existing}, ${file}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pagesBySlug.set(slug, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = new Set();
|
||||||
|
for (const entry of meta.pages) {
|
||||||
|
if (isNavigationDecoration(entry)) continue;
|
||||||
|
if (listed.has(entry)) {
|
||||||
|
throw new Error(`meta.json lists page more than once: ${entry}`);
|
||||||
|
}
|
||||||
|
if (!pagesBySlug.has(entry)) {
|
||||||
|
throw new Error(`meta.json references unknown page: ${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
listed.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of pagesBySlug.keys()) {
|
||||||
|
if (!listed.has(slug)) {
|
||||||
|
throw new Error(`meta.json is missing published page: ${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pageCount: pagesBySlug.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and validate the shape of public navigation metadata.
|
||||||
|
*
|
||||||
|
* @param {string} docsDir Directory containing meta.json.
|
||||||
|
* @returns {Promise<{pages: string[]}>} Parsed navigation metadata.
|
||||||
|
*/
|
||||||
|
async function readMeta(docsDir) {
|
||||||
|
const raw = await fs.readFile(path.join(docsDir, "meta.json"), "utf8");
|
||||||
|
const meta = JSON.parse(raw);
|
||||||
|
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||||
|
throw new Error("meta.json must contain a JSON object");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(meta.pages) ||
|
||||||
|
!meta.pages.every((entry) => typeof entry === "string")
|
||||||
|
) {
|
||||||
|
throw new Error("meta.json pages must be an array of strings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect Markdown paths relative to the published docs root.
|
||||||
|
*
|
||||||
|
* @param {string} dir Published docs root.
|
||||||
|
* @param {string} [relativeDir] Directory relative to the docs root.
|
||||||
|
* @returns {Promise<string[]>} Sorted relative Markdown paths.
|
||||||
|
*/
|
||||||
|
async function collectMarkdownFiles(dir, relativeDir = "") {
|
||||||
|
const entries = await fs.readdir(path.join(dir, relativeDir), {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const relativePath = path.posix.join(relativeDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return collectMarkdownFiles(dir, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\.mdx?$/.test(entry.name) ? [relativePath] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return files.flat().sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require non-empty title and description fields in leading frontmatter.
|
||||||
|
*
|
||||||
|
* @param {string} file Relative page path used in validation errors.
|
||||||
|
* @param {string} markdown Page source.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function assertFrontmatter(file, markdown) {
|
||||||
|
const block = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
||||||
|
if (
|
||||||
|
!block ||
|
||||||
|
!/^title:\s*\S.*$/m.test(block) ||
|
||||||
|
!/^description:\s*\S.*$/m.test(block)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`${file} must start with YAML frontmatter containing title and description`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether a metadata entry is a navigation heading.
|
||||||
|
*
|
||||||
|
* @param {string} entry Navigation metadata entry.
|
||||||
|
* @returns {boolean} Whether the entry is a heading decoration.
|
||||||
|
*/
|
||||||
|
function isNavigationDecoration(entry) {
|
||||||
|
return /^---.*---$/.test(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
validatePublicDocs()
|
||||||
|
.then(({ pageCount }) =>
|
||||||
|
console.log(`Validated ${pageCount} published docs pages.`),
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { after } from "node:test";
|
||||||
|
import { validatePublicDocs } from "./validate-public-docs.mjs";
|
||||||
|
|
||||||
|
const tempDirs = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an isolated published-docs fixture.
|
||||||
|
*
|
||||||
|
* @param {Record<string, string>} files Fixture files keyed by relative path.
|
||||||
|
* @param {{pages: string[]}} meta Navigation metadata.
|
||||||
|
* @returns {Promise<string>} Temporary fixture directory.
|
||||||
|
*/
|
||||||
|
async function createDocs(files, meta) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-public-docs-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(files).map(async ([file, content]) => {
|
||||||
|
const target = path.join(dir, file);
|
||||||
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await fs.writeFile(target, content);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const validPage = `---
|
||||||
|
title: "Overview"
|
||||||
|
description: "Start using Kandev."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kandev
|
||||||
|
|
||||||
|
Page body.
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("accepts explicitly ordered pages with required frontmatter", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{
|
||||||
|
"README.md": "# Contributing",
|
||||||
|
"index.md": validPage,
|
||||||
|
"cli.md": validPage,
|
||||||
|
},
|
||||||
|
{ title: "Kandev Docs", pages: ["---Start---", "index", "cli"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.doesNotReject(validatePublicDocs(dir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects published pages omitted from meta.json", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{ "index.md": validPage, "cli.md": validPage },
|
||||||
|
{ pages: ["index"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
validatePublicDocs(dir),
|
||||||
|
/meta.json is missing published page: cli/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects meta.json entries without a matching file", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{ "index.md": validPage },
|
||||||
|
{ pages: ["index", "nonexistent"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
validatePublicDocs(dir),
|
||||||
|
/meta.json references unknown page: nonexistent/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsupported link decorations as unknown pages", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{ "index.md": validPage },
|
||||||
|
{ pages: ["index", "external:[Support](https://example.com)"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
validatePublicDocs(dir),
|
||||||
|
/meta.json references unknown page: external:\[Support\]\(https:\/\/example.com\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate entries in meta.json", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{ "index.md": validPage },
|
||||||
|
{ pages: ["index", "index"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
validatePublicDocs(dir),
|
||||||
|
/meta.json lists page more than once: index/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects files that resolve to the same published slug", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{ "foo.md": validPage, "foo/index.md": validPage },
|
||||||
|
{ pages: ["foo"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
validatePublicDocs(dir),
|
||||||
|
/multiple published files resolve to slug foo: foo.md, foo\/index.md/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts single-character frontmatter values", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{
|
||||||
|
"index.md": `---
|
||||||
|
title: x
|
||||||
|
description: y
|
||||||
|
---
|
||||||
|
|
||||||
|
# X
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{ pages: ["index"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.doesNotReject(validatePublicDocs(dir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects published pages without title and description frontmatter", async () => {
|
||||||
|
const dir = await createDocs(
|
||||||
|
{ "index.md": "# Kandev\n" },
|
||||||
|
{ pages: ["index"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
validatePublicDocs(dir),
|
||||||
|
/index.md must start with YAML frontmatter containing title and description/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user