Skip to content
26 changes: 7 additions & 19 deletions app/composables/useNavigation.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
Expand All @@ -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
Expand Down Expand Up @@ -68,20 +58,19 @@ export function useMainNavigation(): ComputedRef<NavigationMenuItem[]> {

return computed<NavigationMenuItem[]>(() => {
const base = content.value.base
const seg = segmentOf(route.path, base)

const bySegment = new Map<string, NavigationItem>()
for (const item of navigation.value ?? []) bySegment.set(segmentOf(item.path, base), item)

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,
Expand All @@ -98,7 +87,7 @@ export function useMainNavigation(): ComputedRef<NavigationMenuItem[]> {
items.push({
label: group.label,
to: group.link === 'section' ? node.path : firstLeaf(node),
active: (group.sections ?? []).includes(seg),
active: isNavGroupActive(group, route.path, base),
})
}

Expand All @@ -124,11 +113,10 @@ export function useFilteredNavigation(): ComputedRef<NavigationItem[]> {

return computed<NavigationItem[]>(() => {
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
Expand Down
31 changes: 31 additions & 0 deletions app/utils/navigation.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion playground/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
],
},
Expand Down
4 changes: 2 additions & 2 deletions playground/content/2.writing/5.navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion playground/content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
3 changes: 3 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
58 changes: 57 additions & 1 deletion test/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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)
})
})
Loading