Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 { basename, dirname, extname } from 'node:path'

Check failure on line 1 in packages/core/src/sync/resolve/path.ts

View workflow job for this annotation

GitHub Actions / ci

eslint(no-unused-vars)

Identifier 'basename' is imported but never used.

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 (overview > introduction > intro > 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 @@
* @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 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.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