diff --git a/.changeset/fix-nav-entry-links.md b/.changeset/fix-nav-entry-links.md new file mode 100644 index 0000000..054f8e7 --- /dev/null +++ b/.changeset/fix-nav-entry-links.md @@ -0,0 +1,5 @@ +--- +'@zpress/core': patch +--- + +Auto-nav now links to entry pages (overview, introduction, index, readme) instead of generated landing pages diff --git a/packages/core/src/sync/resolve/path.ts b/packages/core/src/sync/resolve/path.ts index 9209a45..43d93b9 100644 --- a/packages/core/src/sync/resolve/path.ts +++ b/packages/core/src/sync/resolve/path.ts @@ -1,7 +1,41 @@ -import path from 'node:path' +import { dirname, extname } from 'node:path' import { match } from 'ts-pattern' +/** + * Slugs that identify entry/overview pages, in priority order. + * + * Used by the sort comparator (pinned-first) and by auto-nav resolution + * to prefer a real content page over a generated landing page. + */ +export const ENTRY_SLUGS = ['introduction', 'intro', 'overview', 'index', 'readme'] as const + +/** + * Check whether a URL-style link ends with a known entry-page slug. + * + * @param link - URL path (e.g. "/guides/overview") + * @returns True when the last segment matches an entry slug + */ +export function isEntrySlug(link: string): boolean { + const last = link.split('/').pop() + if (!last) { + return false + } + return (ENTRY_SLUGS as readonly string[]).includes(last.toLowerCase()) +} + +/** + * Return the priority index of an entry slug, or -1 if not an entry slug. + * + * Lower values indicate higher priority (introduction > intro > overview > index > readme). + * + * @param slug - Bare filename stem (e.g. "overview", "readme") + * @returns Index in ENTRY_SLUGS, or -1 + */ +export function entrySlugRank(slug: string): number { + return (ENTRY_SLUGS as readonly string[]).indexOf(slug.toLowerCase()) +} + /** * Convert "/guides/add-api-route" → "guides/add-api-route.md" * @@ -32,7 +66,7 @@ export function linkToOutputPath(link: string, ext = '.md'): string { * @returns ".mdx" for MDX files, ".md" for everything else */ export function sourceExt(filePath: string): string { - return match(path.extname(filePath)) + return match(extname(filePath)) .with('.mdx', () => '.mdx') .otherwise(() => '.md') } @@ -46,10 +80,10 @@ export function sourceExt(filePath: string): string { export function extractBaseDir(globPattern: string): string { const firstGlobChar = globPattern.search(/[*?{}[\]]/) if (firstGlobChar === -1) { - return path.dirname(globPattern) + return dirname(globPattern) } const beforeGlob = globPattern.slice(0, firstGlobChar) return match(beforeGlob.endsWith('/')) .with(true, () => beforeGlob.slice(0, -1)) - .otherwise(() => path.dirname(beforeGlob)) + .otherwise(() => dirname(beforeGlob)) } diff --git a/packages/core/src/sync/resolve/sort.ts b/packages/core/src/sync/resolve/sort.ts index 6d77f1f..daac83e 100644 --- a/packages/core/src/sync/resolve/sort.ts +++ b/packages/core/src/sync/resolve/sort.ts @@ -4,15 +4,14 @@ import { match, P } from 'ts-pattern' import type { ResolvedPage } from '../../types.ts' import type { ResolvedEntry } from '../types.ts' - -const PINNED_STEMS = ['introduction', 'intro', 'overview', 'readme'] as const +import { entrySlugRank } from './path.ts' /** * Sort resolved entries using the specified strategy. * * Sections (entries with children) always sort before leaf pages. - * When no sort strategy is provided, entries are sorted with pinned intro-style - * files first (introduction, intro, overview, readme), then alpha by title. + * When no sort strategy is provided, entries are sorted with pinned entry-style + * files first (see `ENTRY_SLUGS`), then alpha by title. * * @param entries - Entries to sort * @param sort - Sort strategy: `"default"` (pinned + alpha), `"alpha"` by text, `"filename"` by output path, or custom comparator @@ -98,7 +97,7 @@ function sectionFirst(a: ResolvedEntry, b: ResolvedEntry): number { * * @private * @param entry - Entry to rank - * @returns Index in PINNED_STEMS, or -1 if not a pinned file + * @returns Index in ENTRY_SLUGS, or -1 if not a pinned file */ function pinnedRank(entry: ResolvedEntry): number { if (!entry.page) { @@ -108,12 +107,12 @@ function pinnedRank(entry: ResolvedEntry): number { if (!source) { return -1 } - const stem = basename(source, extname(source)).toLowerCase() - return (PINNED_STEMS as readonly string[]).indexOf(stem) + const stem = basename(source, extname(source)) + return entrySlugRank(stem) } /** - * Sort pinned intro-style files before all others, preserving their relative order. + * Sort pinned entry-style files before all others, preserving their relative order. * * @private * @param a - First entry to compare diff --git a/packages/core/src/sync/sidebar/index.ts b/packages/core/src/sync/sidebar/index.ts index c45ee60..3dadfc4 100644 --- a/packages/core/src/sync/sidebar/index.ts +++ b/packages/core/src/sync/sidebar/index.ts @@ -1,6 +1,7 @@ import { log } from '@clack/prompts' import type { NavItem, ZpressConfig } from '../../types.ts' +import { isEntrySlug } from '../resolve/path.ts' import type { ResolvedEntry, RspressNavItem, SidebarItem } from '../types.ts' /** @@ -152,13 +153,45 @@ function maybeLink(link: string | undefined): { link?: string } { } /** - * Resolve the link for a nav entry, falling back to the first child link. + * Find a child entry whose link ends with a known entry-page slug. + * + * Checks children (one level) for links matching entry slugs (overview, + * introduction, intro, index, readme). Returns the first match found, + * preferring earlier slugs in priority order. + * + * @private + * @param items - Direct children of a section + * @returns Link of the first matching entry page, or undefined + */ +function findEntryPageLink(items: readonly ResolvedEntry[]): string | undefined { + const entryChildren = items + .filter((c) => !c.hidden && c.link) + .filter((c) => isEntrySlug(c.link as string)) + if (entryChildren.length === 0) { + return undefined + } + return entryChildren[0].link +} + +/** + * Resolve the link for a nav entry. + * + * For sections with children, prefers a child whose slug matches a known + * entry-page name (overview, introduction, index, readme) so the nav + * points to an actual content page rather than a generated landing page. + * Falls back to the section's own link, then the first child link. * * @private * @param entry - Resolved entry to extract link from * @returns Link string or undefined */ function resolveLink(entry: ResolvedEntry): string | undefined { + if (entry.items && entry.items.length > 0) { + const entryPage = findEntryPageLink(entry.items) + if (entryPage) { + return entryPage + } + } if (entry.link) { return entry.link } diff --git a/zpress.config.ts b/zpress.config.ts index 4f827e1..209699f 100644 --- a/zpress.config.ts +++ b/zpress.config.ts @@ -604,11 +604,11 @@ export default defineConfig({ }, ], nav: [ - { title: 'Getting Started', link: '/getting-started' }, - { title: 'Concepts', link: '/concepts' }, - { title: 'Guides', link: '/guides' }, - { title: 'Framework', link: '/framework' }, - { title: 'Reference', link: '/reference' }, + { title: 'Getting Started', link: '/getting-started/introduction' }, + { title: 'Concepts', link: '/concepts/content' }, + { title: 'Guides', link: '/guides/deploying-to-vercel' }, + { title: 'Framework', link: '/framework/overview' }, + { title: 'Reference', link: '/reference/configuration' }, ], socialLinks: [ { icon: 'github', mode: 'link', content: 'https://github.com/joggrdocs/zpress' },