diff --git a/app/composables/useNavigation.ts b/app/composables/useNavigation.ts index 49a8a95..696ee0b 100644 --- a/app/composables/useNavigation.ts +++ b/app/composables/useNavigation.ts @@ -1,17 +1,13 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { NavigationItem } from 'comark-content' import type { NavigationMenuItem } from '@nuxt/ui/components/NavigationMenu.vue' +import { isNavGroupActive, segmentOf } from '../utils/navigation' +import type { NavGroupTarget } from '../utils/navigation' -export interface NavGroup { +export interface NavGroup extends NavGroupTarget { label: string - /** Top-level content sections grouped under this tab. */ - sections?: string[] /** Where the tab links: the first leaf page of the first section (default) or the section index page. */ link?: 'first-leaf' | 'section' - /** Explicit target for a tab backed by an app route rather than content sections. */ - to?: string - /** Path prefix that marks a manual tab active; defaults to `to`. */ - activePath?: string /** Dropdown items for a manual tab. */ children?: NavGroupChild[] } @@ -29,12 +25,6 @@ function firstLeaf(item: NavigationItem): string { return current.path } -/** Logical top-level segment of a path, ignoring the active version `base`. */ -function segmentOf(path: string, base: string): string { - const rel = base && path.startsWith(base) ? path.slice(base.length) : path - return rel.split('/').filter(Boolean)[0] ?? '' -} - function childIsActive(target: string, route: RouteLocationNormalizedLoaded): boolean { const [path, search] = target.split('?') if (!route.path.startsWith(path!)) return false @@ -68,7 +58,6 @@ export function useMainNavigation(): ComputedRef { return computed(() => { const base = content.value.base - const seg = segmentOf(route.path, base) const bySegment = new Map() for (const item of navigation.value ?? []) bySegment.set(segmentOf(item.path, base), item) @@ -76,12 +65,12 @@ export function useMainNavigation(): ComputedRef { const items: NavigationMenuItem[] = [] for (const group of navGroups(navigation.value ?? [], base)) { - // Manual tab: an explicit app-route link, optionally with a dropdown. + // Explicit link, optionally with a dropdown. if (group.to) { items.push({ label: group.label, to: group.to, - active: seg === segmentOf(group.activePath ?? group.to, base), + active: isNavGroupActive(group, route.path, base), ...(group.children?.length && { children: group.children.map((child) => ({ label: child.label, @@ -98,7 +87,7 @@ export function useMainNavigation(): ComputedRef { items.push({ label: group.label, to: group.link === 'section' ? node.path : firstLeaf(node), - active: (group.sections ?? []).includes(seg), + active: isNavGroupActive(group, route.path, base), }) } @@ -124,11 +113,10 @@ export function useFilteredNavigation(): ComputedRef { return computed(() => { const base = content.value.base - const seg = segmentOf(route.path, base) const nav = navigation.value ?? [] const groups = navGroups(nav, base) - const active = groups.find((group) => group.sections?.includes(seg)) ?? groups[0] + const active = groups.find((group) => isNavGroupActive(group, route.path, base)) ?? groups[0] const sections = active?.sections // Manual tabs (no sections) have no content sidebar; fall back to the full tree. if (!sections?.length) return nav diff --git a/app/utils/navigation.ts b/app/utils/navigation.ts index 0cb746b..c52f3fc 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -1,6 +1,37 @@ import type { NavigationItem } from 'comark-content' import type { RouteLocationNormalized } from 'vue-router' +/** Logical top-level segment of a path, ignoring the active version `base`. */ +export function segmentOf(path: string, base: string): string { + const rel = base && path.startsWith(base) ? path.slice(base.length) : path + return rel.split('/').filter(Boolean)[0] ?? '' +} + +/** What decides where a header tab links and when it is active. */ +export interface NavGroupTarget { + /** Top-level content sections grouped under this tab. */ + sections?: string[] + /** + * Explicit link target. Alone it makes a manual tab backed by an app route; together with `sections` + * the tab still owns those sections for the sidebar and its active state, and is also active under `to`. + */ + to?: string + /** Path prefix that marks the tab active; defaults to `to`. */ + activePath?: string +} + +/** + * Whether a header tab is active on `path`: on any of its `sections`, and under `activePath` or its + * own `to` (a target that renders a page rather than redirecting). + */ +export function isNavGroupActive(group: NavGroupTarget, path: string, base: string): boolean { + const seg = segmentOf(path, base) + if (group.sections?.includes(seg)) return true + // A target may carry a query or hash (`/play?example=basic`); only its path names a segment. + const target = group.activePath ?? group.to + return !!target && seg === segmentOf(target.split(/[?#]/)[0]!, base) +} + export function findPageHeadline( navigation: NavigationItem[] | undefined | null, path: string | undefined diff --git a/playground/app/app.config.ts b/playground/app/app.config.ts index e3915be..ece0837 100644 --- a/playground/app/app.config.ts +++ b/playground/app/app.config.ts @@ -11,7 +11,7 @@ export default defineAppConfig({ }, ecosystem: [{ mark: 'comark-content', to: 'https://content.comark.dev', label: 'Comark Content' }], nav: [ - { label: 'Documentation', sections: ['getting-started', 'writing', 'concepts', 'deployment'] }, + { label: 'Documentation', to: '/docs', sections: ['getting-started', 'writing', 'concepts', 'deployment'] }, { label: 'Page', to: '/page' } ], }, diff --git a/playground/content/2.writing/5.navigation.md b/playground/content/2.writing/5.navigation.md index 977b15d..a2ded83 100644 --- a/playground/content/2.writing/5.navigation.md +++ b/playground/content/2.writing/5.navigation.md @@ -63,8 +63,8 @@ When `header.nav` is empty (the default), the layer derives one tab per top-leve | `label` | Tab text. | | `sections` | Top-level content directories (without numeric prefix) grouped under this tab. | | `link` | Where the tab links: `'first-leaf'` (default, the first page of the first section) or `'section'` (the section's index page). | -| `to` | Makes a manual tab pointing at an app route instead of content sections. | -| `activePath` | Path prefix that marks a manual tab active; defaults to `to`. | +| `to` | Explicit link target. Alone it makes a manual tab pointing at an app route; with `sections` the tab keeps its sections for the sidebar and active state and links to `to` instead. | +| `activePath` | Path prefix that also marks the tab active; defaults to `to`. | | `children` | Dropdown items for a manual tab: `{ label, to, activePath? }`. | A GitHub tab is appended automatically when the repository is known. diff --git a/playground/content/index.md b/playground/content/index.md index 9b45699..f3d3016 100644 --- a/playground/content/index.md +++ b/playground/content/index.md @@ -17,7 +17,7 @@ The first Markdown-driven docs site where content goes live on `git push`. No re #links :::button --- - to: /getting-started/introduction + to: /docs size: lg trailing-icon: i-lucide-arrow-right --- diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 80c98d5..e138681 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,5 +1,8 @@ export default defineNuxtConfig({ extends: ['..'], + routeRules: { + '/docs': { redirect: '/getting-started/introduction' }, + }, site: { url: 'https://docs-template.comark.dev', name: 'Comark Docs Template', diff --git a/test/navigation.test.ts b/test/navigation.test.ts index 8110ef2..14f99c4 100644 --- a/test/navigation.test.ts +++ b/test/navigation.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { findBreadcrumb, findNavigationLayout, findPageHeadline, findSurroundLinks } from '../app/utils/navigation' +import { + findBreadcrumb, + findNavigationLayout, + findPageHeadline, + findSurroundLinks, + isNavGroupActive, + segmentOf, +} from '../app/utils/navigation' import type { NavigationItem } from 'comark-content' const nav = [ @@ -173,3 +180,52 @@ describe('findSurroundLinks', () => { expect(findSurroundLinks(null, '/a')).toEqual([]) }) }) + +describe('segmentOf', () => { + it('returns the first path segment', () => { + expect(segmentOf('/getting-started/installation', '')).toBe('getting-started') + expect(segmentOf('/', '')).toBe('') + }) + + it('ignores the version base', () => { + expect(segmentOf('/tree/main/syntax/markdown', '/tree/main')).toBe('syntax') + expect(segmentOf('/tree/main', '/tree/main')).toBe('') + }) +}) + +describe('isNavGroupActive', () => { + it('marks a sections tab active on any of its sections', () => { + const group = { sections: ['getting-started', 'syntax'] } + expect(isNavGroupActive(group, '/syntax/markdown', '')).toBe(true) + expect(isNavGroupActive(group, '/plugins', '')).toBe(false) + }) + + it('keeps the sections for a tab that links elsewhere and is active under its target too', () => { + const group = { to: '/guide', sections: ['getting-started', 'syntax'] } + expect(isNavGroupActive(group, '/getting-started/introduction', '')).toBe(true) + expect(isNavGroupActive(group, '/guide', '')).toBe(true) + expect(isNavGroupActive(group, '/plugins', '')).toBe(false) + }) + + it('lets activePath decide for a sections tab outside its sections', () => { + const group = { to: '/guide', sections: ['syntax'], activePath: '/handbook' } + expect(isNavGroupActive(group, '/syntax/markdown', '')).toBe(true) + expect(isNavGroupActive(group, '/handbook/intro', '')).toBe(true) + expect(isNavGroupActive(group, '/guide', '')).toBe(false) + expect(isNavGroupActive({ sections: ['syntax'], activePath: '/handbook' }, '/handbook/intro', '')).toBe(true) + }) + + it('marks a manual tab active under its link or activePath', () => { + expect(isNavGroupActive({ to: '/play' }, '/play', '')).toBe(true) + expect(isNavGroupActive({ to: '/play' }, '/play/booking', '')).toBe(true) + expect(isNavGroupActive({ to: '/play?example=basic', activePath: '/play' }, '/play', '')).toBe(true) + expect(isNavGroupActive({ to: '/play?example=basic' }, '/play', '')).toBe(true) + expect(isNavGroupActive({ to: '/play#demo' }, '/play/booking', '')).toBe(true) + expect(isNavGroupActive({ to: '/play' }, '/syntax', '')).toBe(false) + }) + + it('respects the version base', () => { + expect(isNavGroupActive({ sections: ['syntax'] }, '/tree/main/syntax/markdown', '/tree/main')).toBe(true) + expect(isNavGroupActive({ to: '/play' }, '/tree/main/play', '/tree/main')).toBe(true) + }) +})