diff --git a/examples/kitchen-sink/docs/references/api/authentication.md b/examples/kitchen-sink/docs/references/api/authentication.md new file mode 100644 index 00000000..41756f71 --- /dev/null +++ b/examples/kitchen-sink/docs/references/api/authentication.md @@ -0,0 +1,21 @@ +--- +title: Authentication +--- + +# Authentication + +All API requests require a Bearer token in the `Authorization` header. + +## Obtaining a Token + +Send a `POST` request to `/auth/token` with your client credentials: + +```bash +curl -X POST https://api.acme.com/auth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id": "...", "client_secret": "..."}' +``` + +## Token Refresh + +Tokens expire after 1 hour. Use the refresh token to obtain a new access token without re-authenticating. diff --git a/examples/kitchen-sink/docs/references/api/endpoints.md b/examples/kitchen-sink/docs/references/api/endpoints.md new file mode 100644 index 00000000..6364efe5 --- /dev/null +++ b/examples/kitchen-sink/docs/references/api/endpoints.md @@ -0,0 +1,23 @@ +--- +title: Endpoints +--- + +# Endpoints + +## Users + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/users` | List all users | +| `GET` | `/users/:id` | Get user by ID | +| `POST` | `/users` | Create a user | +| `PATCH` | `/users/:id` | Update a user | +| `DELETE` | `/users/:id` | Delete a user | + +## Projects + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/projects` | List all projects | +| `GET` | `/projects/:id` | Get project by ID | +| `POST` | `/projects` | Create a project | diff --git a/examples/kitchen-sink/docs/references/api/errors.md b/examples/kitchen-sink/docs/references/api/errors.md new file mode 100644 index 00000000..27eedd21 --- /dev/null +++ b/examples/kitchen-sink/docs/references/api/errors.md @@ -0,0 +1,16 @@ +--- +title: Error Codes +--- + +# Error Codes + +All errors return a JSON body with `code`, `message`, and optional `details`. + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `auth_required` | 401 | Missing or invalid authentication | +| `forbidden` | 403 | Insufficient permissions | +| `not_found` | 404 | Resource does not exist | +| `validation_error` | 422 | Request body failed validation | +| `rate_limited` | 429 | Too many requests | +| `internal_error` | 500 | Unexpected server error | diff --git a/examples/kitchen-sink/docs/references/cli/commands.md b/examples/kitchen-sink/docs/references/cli/commands.md new file mode 100644 index 00000000..82f72a54 --- /dev/null +++ b/examples/kitchen-sink/docs/references/cli/commands.md @@ -0,0 +1,29 @@ +--- +title: Commands +--- + +# Commands + +## `acme init` + +Initialize a new Acme project in the current directory. + +```bash +acme init [--template ] +``` + +## `acme deploy` + +Deploy the current project to production. + +```bash +acme deploy [--env ] [--dry-run] +``` + +## `acme status` + +Show the current deployment status. + +```bash +acme status [--json] +``` diff --git a/examples/kitchen-sink/docs/references/cli/configuration.md b/examples/kitchen-sink/docs/references/cli/configuration.md new file mode 100644 index 00000000..9e8daf93 --- /dev/null +++ b/examples/kitchen-sink/docs/references/cli/configuration.md @@ -0,0 +1,27 @@ +--- +title: Configuration +--- + +# Configuration + +The CLI reads configuration from `acme.config.ts` in the project root. + +```ts +export default { + org: 'acme', + project: 'web', + region: 'us-east-1', + deploy: { + strategy: 'rolling', + timeout: 300, + }, +} +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ACME_TOKEN` | API authentication token | — | +| `ACME_ORG` | Organization slug | from config | +| `ACME_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` | diff --git a/examples/kitchen-sink/docs/references/cli/installation.md b/examples/kitchen-sink/docs/references/cli/installation.md new file mode 100644 index 00000000..dc4bbb72 --- /dev/null +++ b/examples/kitchen-sink/docs/references/cli/installation.md @@ -0,0 +1,22 @@ +--- +title: Installation +--- + +# Installation + +Install the Acme CLI globally: + +```bash +npm install -g @acme/cli +``` + +Verify the installation: + +```bash +acme --version +``` + +## System Requirements + +- Node.js 18 or later +- macOS, Linux, or Windows (WSL) diff --git a/examples/kitchen-sink/zpress.config.ts b/examples/kitchen-sink/zpress.config.ts index 827703ed..10df46b8 100644 --- a/examples/kitchen-sink/zpress.config.ts +++ b/examples/kitchen-sink/zpress.config.ts @@ -141,6 +141,26 @@ export default defineConfig({ }, ], }, + { + title: 'Reference', + icon: 'pixelarticons:book-open', + path: '/references', + root: true, + items: [ + { + title: 'API', + path: '/references/api', + include: 'docs/references/api/*.md', + sort: 'alpha', + }, + { + title: 'CLI', + path: '/references/cli', + include: 'docs/references/cli/*.md', + sort: 'alpha', + }, + ], + }, ], sidebar: { above: [ diff --git a/packages/config/schemas/schema.json b/packages/config/schemas/schema.json index 7995f896..4968b966 100644 --- a/packages/config/schemas/schema.json +++ b/packages/config/schemas/schema.json @@ -392,6 +392,9 @@ }, "standalone": { "type": "boolean" + }, + "root": { + "type": "boolean" } }, "required": [ diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 84eba2d8..10a678cc 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -116,6 +116,7 @@ const entrySchema: z.ZodType
= z.lazy(() => icon: iconConfigSchema.optional(), card: cardConfigSchema.optional(), standalone: z.boolean().optional(), + root: z.boolean().optional(), }) .strict() ) diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index cb69954c..aef2a4e2 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -264,6 +264,7 @@ export interface Section { readonly icon?: IconConfig readonly card?: CardConfig readonly standalone?: boolean + readonly root?: boolean } /** diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 59610e76..b07c2795 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -389,7 +389,7 @@ function concatPage(pages: readonly PageData[], page: PageData | undefined): Pag * @returns Array of standalone scope path strings */ function collectStandaloneScopePaths(entries: readonly ResolvedEntry[]): readonly string[] { - return entries.filter((e) => e.standalone && e.link).map((e) => e.link as string) + return entries.filter((e) => (e.standalone || e.root) && e.link).map((e) => e.link as string) } /** diff --git a/packages/core/src/sync/resolve/index.ts b/packages/core/src/sync/resolve/index.ts index 9e0f16ff..9f33b425 100644 --- a/packages/core/src/sync/resolve/index.ts +++ b/packages/core/src/sync/resolve/index.ts @@ -244,6 +244,7 @@ async function resolveNestedSection( card: section.card, landing: section.landing, standalone: section.standalone, + root: section.root, autoLink, items: sorted, page: sectionPage, diff --git a/packages/core/src/sync/sidebar/index.ts b/packages/core/src/sync/sidebar/index.ts index 23ce7fa3..4ca81166 100644 --- a/packages/core/src/sync/sidebar/index.ts +++ b/packages/core/src/sync/sidebar/index.ts @@ -23,8 +23,8 @@ export function generateNav( // Auto: first 3 non-standalone sections (matching home page features), // plus all standalone sections (workspace dropdowns). const visible = resolved.filter((e) => !e.hidden) - const nonStandalone = visible.filter((e) => !e.standalone).slice(0, 3) - const standalone = visible.filter((e) => e.standalone) + const nonStandalone = visible.filter((e) => !e.standalone && !e.root).slice(0, 3) + const standalone = visible.filter((e) => e.standalone || e.root) return [...nonStandalone, ...standalone] .map(buildNavEntry) @@ -127,7 +127,7 @@ function resolveLink(entry: ResolvedEntry): string | undefined { * @returns Array of nav items for dropdown, or undefined */ function resolveChildren(entry: ResolvedEntry): readonly RspressNavItem[] | undefined { - if (entry.standalone && entry.items && entry.items.length > 0) { + if ((entry.standalone || entry.root) && entry.items && entry.items.length > 0) { return entry.items .filter((child) => !child.hidden) .map( diff --git a/packages/core/src/sync/sidebar/meta.test.ts b/packages/core/src/sync/sidebar/meta.test.ts index 7c0cd0df..914d0ccc 100644 --- a/packages/core/src/sync/sidebar/meta.test.ts +++ b/packages/core/src/sync/sidebar/meta.test.ts @@ -77,6 +77,36 @@ const packagesRoot: ResolvedEntry = { ], } +const referenceRoot: ResolvedEntry = { + title: 'Reference', + link: '/references', + root: true, + items: [ + { + title: 'API', + link: '/references/api', + items: [ + { + title: 'Auth', + link: '/references/api/auth', + page: { outputPath: 'references/api/auth.md', frontmatter: {} }, + }, + ], + }, + { + title: 'CLI', + link: '/references/cli', + items: [ + { + title: 'Commands', + link: '/references/cli/commands', + page: { outputPath: 'references/cli/commands.md', frontmatter: {} }, + }, + ], + }, + ], +} + // --------------------------------------------------------------------------- // buildRootMeta // --------------------------------------------------------------------------- @@ -111,6 +141,51 @@ describe(buildRootMeta, () => { expect(result).toHaveLength(1) expect(result[0]).toMatchObject({ name: 'visible' }) }) + + it('should promote root section children to top-level meta items', () => { + const entries: readonly ResolvedEntry[] = [ + { title: 'Getting Started', link: '/getting-started', items: [] }, + referenceRoot, + ] + + const result = buildRootMeta(entries) + + expect(result).toEqual([ + { type: 'dir', name: 'getting-started', label: 'Getting Started' }, + { type: 'dir', name: 'api', label: 'API' }, + { type: 'dir', name: 'cli', label: 'CLI' }, + ]) + }) + + it('should not include root section parent as a meta item', () => { + const entries: readonly ResolvedEntry[] = [referenceRoot] + + const result = buildRootMeta(entries) + + const names = result + .filter( + (item): item is { readonly type: string; readonly name: string; readonly label: string } => + 'name' in item + ) + .map((item) => item.name) + expect(names).not.toContain('references') + }) + + it('should exclude hidden children from root section promotion', () => { + const rootWithHidden: ResolvedEntry = { + title: 'Reference', + link: '/references', + root: true, + items: [ + { title: 'API', link: '/references/api', items: [] }, + { title: 'Internal', link: '/references/internal', hidden: true, items: [] }, + ], + } + + const result = buildRootMeta([rootWithHidden]) + + expect(result).toEqual([{ type: 'dir', name: 'api', label: 'API' }]) + }) }) // --------------------------------------------------------------------------- @@ -214,6 +289,39 @@ describe(buildMetaDirectories, () => { } }) + it('should flatten root section children without emitting parent directory group', () => { + const directories = buildMetaDirectories([referenceRoot]) + + const dirPaths = directories.map((d) => d.dirPath) + expect(dirPaths).not.toContain('references') + }) + + it('should emit subdirectories for root section children', () => { + const directories = buildMetaDirectories([referenceRoot]) + + const apiDir = directories.find((d) => d.dirPath === 'references/api') + expect(apiDir).toBeDefined() + if (apiDir) { + expect(apiDir.items).toContainEqual({ type: 'file', name: 'auth', label: 'Auth' }) + } + + const cliDir = directories.find((d) => d.dirPath === 'references/cli') + expect(cliDir).toBeDefined() + if (cliDir) { + expect(cliDir.items).toContainEqual({ type: 'file', name: 'commands', label: 'Commands' }) + } + }) + + it('should handle mix of root and non-root sections', () => { + const directories = buildMetaDirectories([packagesRoot, referenceRoot]) + + const dirPaths = directories.map((d) => d.dirPath) + expect(dirPaths).toContain('packages') + expect(dirPaths).not.toContain('references') + expect(dirPaths).toContain('references/api') + expect(dirPaths).toContain('references/cli') + }) + it('should preserve leaf-before-section order when names do not collide', () => { const mixedSection: ResolvedEntry = { title: 'Mixed', diff --git a/packages/core/src/sync/sidebar/meta.ts b/packages/core/src/sync/sidebar/meta.ts index 7fa72eb4..c4ee2af7 100644 --- a/packages/core/src/sync/sidebar/meta.ts +++ b/packages/core/src/sync/sidebar/meta.ts @@ -81,6 +81,24 @@ export function buildRootMeta(entries: readonly ResolvedEntry[]): readonly MetaI return entries .filter((e) => !e.hidden) .flatMap((entry) => { + if (entry.root && entry.items) { + return entry.items + .filter((child) => !child.hidden) + .flatMap((child) => { + const name = resolveDirName(child) + if (name === null) { + return [] + } + return [ + { + type: 'dir' as const, + name, + label: child.title, + }, + ] + }) + } + const name = resolveDirName(entry) if (name === null) { return [] @@ -108,8 +126,20 @@ export function buildRootMeta(entries: readonly ResolvedEntry[]): readonly MetaI * @returns Flat array of directories needing `_meta.json` files */ export function buildMetaDirectories(entries: readonly ResolvedEntry[]): readonly MetaDirectory[] { - const { placements } = flattenToPlacements(entries, 0) - return groupPlacementsByDir(placements) + const rootParentDirs = new Set( + entries + .filter((entry) => entry.root && entry.link) + .map((entry) => stripLeadingSlash(entry.link ?? '')) + .filter(Boolean) + ) + const expanded = entries.flatMap((entry) => { + if (entry.root && entry.items) { + return entry.items.filter((child) => !child.hidden) + } + return [entry] + }) + const { placements } = flattenToPlacements(expanded, 0) + return groupPlacementsByDir(placements).filter((dir) => !rootParentDirs.has(dir.dirPath)) } // --------------------------------------------------------------------------- diff --git a/packages/core/src/sync/sidebar/nav.test.ts b/packages/core/src/sync/sidebar/nav.test.ts new file mode 100644 index 00000000..3da69422 --- /dev/null +++ b/packages/core/src/sync/sidebar/nav.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import type { ZpressConfig } from '../../types.ts' +import type { ResolvedEntry } from '../types.ts' +import { generateNav } from './index.ts' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const autoConfig = { nav: 'auto' } as ZpressConfig + +// --------------------------------------------------------------------------- +// generateNav — root sections +// --------------------------------------------------------------------------- + +describe(generateNav, () => { + it('should exclude root sections from non-standalone nav items', () => { + const entries: readonly ResolvedEntry[] = [ + { title: 'Guide', link: '/guide', items: [{ title: 'Intro', link: '/guide/intro' }] }, + { + title: 'Reference', + link: '/references', + root: true, + items: [ + { title: 'API', link: '/references/api', items: [] }, + { title: 'CLI', link: '/references/cli', items: [] }, + ], + }, + ] + + const nav = generateNav(autoConfig, entries) + const texts = nav.map((item) => item.text) + + expect(texts).toContain('Guide') + expect(texts).toContain('Reference') + }) + + it('should produce dropdown children for root sections', () => { + const entries: readonly ResolvedEntry[] = [ + { + title: 'Reference', + link: '/references', + root: true, + items: [ + { title: 'API', link: '/references/api', items: [] }, + { title: 'CLI', link: '/references/cli', items: [] }, + ], + }, + ] + + const nav = generateNav(autoConfig, entries) + const refItem = nav.find((item) => item.text === 'Reference') + + expect(refItem).toBeDefined() + if (refItem && 'items' in refItem) { + const childTexts = (refItem.items as readonly { readonly text: string }[]).map((c) => c.text) + expect(childTexts).toEqual(['API', 'CLI']) + } + }) + + it('should not count root sections toward the 3 non-standalone limit', () => { + const entries: readonly ResolvedEntry[] = [ + { title: 'A', link: '/a', items: [{ title: 'A1', link: '/a/1' }] }, + { title: 'B', link: '/b', items: [{ title: 'B1', link: '/b/1' }] }, + { title: 'C', link: '/c', items: [{ title: 'C1', link: '/c/1' }] }, + { title: 'D', link: '/d', items: [{ title: 'D1', link: '/d/1' }] }, + { + title: 'Ref', + link: '/ref', + root: true, + items: [{ title: 'API', link: '/ref/api', items: [] }], + }, + ] + + const nav = generateNav(autoConfig, entries) + const texts = nav.map((item) => item.text) + + // First 3 non-standalone + root section + expect(texts).toEqual(['A', 'B', 'C', 'Ref']) + expect(texts).not.toContain('D') + }) +}) diff --git a/packages/core/src/sync/types.ts b/packages/core/src/sync/types.ts index bdc21545..44ff4d9d 100644 --- a/packages/core/src/sync/types.ts +++ b/packages/core/src/sync/types.ts @@ -142,6 +142,12 @@ export interface ResolvedEntry { * When true, this section gets its own sidebar namespace keyed by `link`. */ readonly standalone?: boolean + /** + * When true, child sections are promoted to top-level sidebar items + * and the parent title is hidden from the sidebar hierarchy. + * Implies standalone scope isolation. + */ + readonly root?: boolean /** * When true, `link` was auto-derived from `path` or children's common prefix * rather than explicitly set in the config.