diff --git a/.changeset/feat-root-section.md b/.changeset/feat-root-section.md new file mode 100644 index 00000000..b6c938fd --- /dev/null +++ b/.changeset/feat-root-section.md @@ -0,0 +1,5 @@ +--- +'@zpress/core': minor +--- + +feat: support root sections that promote children to top-level sidebar and nav 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..6e441603 100644 --- a/packages/config/schemas/schema.json +++ b/packages/config/schemas/schema.json @@ -98,30 +98,7 @@ "type": "object", "properties": { "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "from": { - "type": "string", - "enum": [ - "auto", - "filename", - "heading", - "frontmatter" - ] - }, - "transform": {} - }, - "required": [ - "from" - ], - "additionalProperties": false - } - ] + "type": "string" }, "icon": { "anyOf": [ @@ -193,7 +170,30 @@ "type": "object", "properties": { "title": { - "$ref": "#/definitions/ZpressConfig/properties/apps/items/properties/title" + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "from": { + "type": "string", + "enum": [ + "auto", + "filename", + "heading", + "frontmatter" + ] + }, + "transform": {} + }, + "required": [ + "from" + ], + "additionalProperties": false + } + ] }, "description": { "type": "string" @@ -392,6 +392,9 @@ }, "standalone": { "type": "boolean" + }, + "root": { + "type": "boolean" } }, "required": [ @@ -478,7 +481,7 @@ "type": "object", "properties": { "title": { - "$ref": "#/definitions/ZpressConfig/properties/apps/items/properties/title" + "type": "string" }, "description": { "type": "string" @@ -511,7 +514,7 @@ "type": "object", "properties": { "title": { - "$ref": "#/definitions/ZpressConfig/properties/apps/items/properties/title" + "type": "string" }, "description": { "type": "string" diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 84eba2d8..8f944ecc 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() ) @@ -131,7 +132,7 @@ const openapiConfigSchema = z const workspaceItemSchema = z .object({ - title: titleConfigSchema, + title: z.string(), icon: iconConfigSchema.optional(), description: z.string(), tags: z.array(z.string()).optional(), @@ -150,7 +151,7 @@ const workspaceItemSchema = z const workspaceGroupSchema = z .object({ - title: titleConfigSchema, + title: z.string(), description: z.string().optional(), icon: iconIdSchema, items: z.array(workspaceItemSchema).min(1), @@ -160,7 +161,7 @@ const workspaceGroupSchema = z const featureSchema = z .object({ - title: titleConfigSchema, + title: z.string(), description: z.string(), link: z.string().optional(), icon: iconConfigSchema.optional(), diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index cb69954c..a76397be 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 } /** @@ -285,7 +286,7 @@ export interface Section { * ``` */ export interface Workspace { - readonly title: TitleConfig + readonly title: string readonly icon?: IconConfig readonly description: string readonly tags?: readonly string[] @@ -324,7 +325,7 @@ export interface Workspace { * ``` */ export interface WorkspaceCategory { - readonly title: TitleConfig + readonly title: string readonly description?: string readonly icon: IconId readonly items: readonly Workspace[] @@ -395,7 +396,7 @@ export interface OpenAPIConfig { * ``` */ export interface Feature { - readonly title: TitleConfig + readonly title: string readonly description: string readonly link?: string readonly icon?: IconConfig diff --git a/packages/core/src/define-config.ts b/packages/core/src/define-config.ts index 7ba64ccc..0914c928 100644 --- a/packages/core/src/define-config.ts +++ b/packages/core/src/define-config.ts @@ -139,16 +139,6 @@ function validateWorkspaces(items: readonly Workspace[]): ConfigResult { } } - if (typeof item.title !== 'string') { - return { - error: configError( - 'invalid_field', - `Workspace "${String(item.title)}": "title" must be a string (TitleConfig not supported on Workspace)` - ), - seen: acc.seen, - } - } - if (!item.description) { return { error: configError( @@ -209,13 +199,6 @@ function validateWorkspaceCategories(categories: readonly WorkspaceCategory[]): return configError('missing_field', 'WorkspaceCategory: "title" is required') } - if (typeof category.title !== 'string') { - return configError( - 'invalid_field', - `WorkspaceCategory: "title" must be a string (TitleConfig not supported on WorkspaceCategory)` - ) - } - if (!category.icon) { return configError( 'missing_field', @@ -393,17 +376,6 @@ function validateFeature(feature: Feature): ConfigError | null { return configError('missing_field', 'Feature: "title" is required') } - const titleStr = match(feature.title) - .with(P.string, (t) => t) - .otherwise(() => 'Feature') - - if (typeof feature.title !== 'string') { - return configError( - 'invalid_field', - `Feature "${titleStr}": "title" must be a string (TitleConfig not supported on Feature)` - ) - } - if (!feature.description) { return configError('missing_field', `Feature "${feature.title}": "description" is required`) } 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..034cc95e 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: 'file', 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..f403723c 100644 --- a/packages/core/src/sync/sidebar/meta.ts +++ b/packages/core/src/sync/sidebar/meta.ts @@ -81,6 +81,21 @@ 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): readonly (MetaDirItem | MetaFileItem)[] => { + const name = resolveDirName(child) + if (name === null) { + return [] + } + if (hasChildren(child)) { + return [{ type: 'dir' as const, name, label: child.title }] + } + return [{ type: 'file' as const, name, label: child.title }] + }) + } + const name = resolveDirName(entry) if (name === null) { return [] @@ -108,8 +123,21 @@ 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 visibleEntries = entries.filter((entry) => !entry.hidden) + const rootParentDirs = new Set( + visibleEntries + .filter((entry) => entry.root && entry.link) + .map((entry) => stripLeadingSlash(entry.link ?? '')) + .filter(Boolean) + ) + const expanded = visibleEntries.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..3d029811 --- /dev/null +++ b/packages/core/src/sync/sidebar/nav.test.ts @@ -0,0 +1,82 @@ +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() + expect(refItem).toHaveProperty('items') + const childTexts = ((refItem as { readonly items: readonly { readonly text: string }[] }).items).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.