Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-nav-entry-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@zpress/core': patch
---

Auto-nav now links to entry pages (overview, introduction, index, readme) instead of generated landing pages
42 changes: 38 additions & 4 deletions packages/core/src/sync/resolve/path.ts
Original file line number Diff line number Diff line change
@@ -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"
*
Expand Down Expand Up @@ -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')
}
Expand All @@ -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))
}
15 changes: 7 additions & 8 deletions packages/core/src/sync/resolve/sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/sync/sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 5 additions & 5 deletions zpress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading