From c883acf3876e62437e9c043f912c016772680e92 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 15:22:05 +0200 Subject: [PATCH 1/8] feat(nav): let a sections tab link to an explicit target --- app/composables/useNavigation.ts | 21 ++++++-------- app/utils/navigation.ts | 22 +++++++++++++++ test/navigation.test.ts | 47 +++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/app/composables/useNavigation.ts b/app/composables/useNavigation.ts index 49a8a95..6a71189 100644 --- a/app/composables/useNavigation.ts +++ b/app/composables/useNavigation.ts @@ -1,6 +1,7 @@ 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' export interface NavGroup { label: string @@ -8,9 +9,12 @@ export interface NavGroup { 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. */ + /** + * Explicit link target. Alone it makes a manual tab backed by an app route; together with `sections` + * the tab still owns those sections for its active state and the sidebar, only the link changes. + */ to?: string - /** Path prefix that marks a manual tab active; defaults to `to`. */ + /** Path prefix that marks a manual tab active; defaults to `to`. Ignored when `sections` is set. */ activePath?: string /** Dropdown items for a manual tab. */ children?: NavGroupChild[] @@ -29,12 +33,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 +66,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 +73,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 +95,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), }) } diff --git a/app/utils/navigation.ts b/app/utils/navigation.ts index 0cb746b..53617fa 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -1,5 +1,27 @@ import type { NavigationItem } from 'comark-content' import type { RouteLocationNormalized } from 'vue-router' +import type { NavGroup } from '../composables/useNavigation' + +/** 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] ?? '' +} + +/** + * Whether a header tab is active on `path`. A tab with `sections` is active on any of them, whatever it + * links to; a manual tab is active under `activePath`, or under its own `to`. + */ +export function isNavGroupActive( + group: Pick, + path: string, + base: string +): boolean { + const seg = segmentOf(path, base) + if (group.sections?.length) return group.sections.includes(seg) + if (group.to) return seg === segmentOf(group.activePath ?? group.to, base) + return false +} export function findPageHeadline( navigation: NavigationItem[] | undefined | null, diff --git a/test/navigation.test.ts b/test/navigation.test.ts index 8110ef2..905c499 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,41 @@ 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', () => { + const group = { to: '/docs', sections: ['getting-started', 'syntax'] } + expect(isNavGroupActive(group, '/getting-started/introduction', '')).toBe(true) + expect(isNavGroupActive(group, '/docs', '')).toBe(false) + }) + + 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' }, '/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) + }) +}) From 963d129f8b0904884a34af6b35aaa0c5b09a8b26 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 15:43:27 +0200 Subject: [PATCH 2/8] fix(nav): ignore the query and hash of a manual tab target --- app/utils/navigation.ts | 3 ++- test/navigation.test.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/utils/navigation.ts b/app/utils/navigation.ts index 53617fa..26125d8 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -19,7 +19,8 @@ export function isNavGroupActive( ): boolean { const seg = segmentOf(path, base) if (group.sections?.length) return group.sections.includes(seg) - if (group.to) return seg === segmentOf(group.activePath ?? group.to, base) + // A manual target may carry a query or hash (`/play?example=basic`); only its path names a segment. + if (group.to) return seg === segmentOf((group.activePath ?? group.to).split(/[?#]/)[0]!, base) return false } diff --git a/test/navigation.test.ts b/test/navigation.test.ts index 905c499..5265cfb 100644 --- a/test/navigation.test.ts +++ b/test/navigation.test.ts @@ -210,6 +210,8 @@ describe('isNavGroupActive', () => { 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) }) From a926413d382b9a19cece5bd106bedbc81a5764db Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 16:11:46 +0200 Subject: [PATCH 3/8] fix(nav): keep a sections tab active under its own target --- app/composables/useNavigation.ts | 7 ++++--- app/utils/navigation.ts | 25 +++++++++++++++---------- test/navigation.test.ts | 14 +++++++++++--- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/app/composables/useNavigation.ts b/app/composables/useNavigation.ts index 6a71189..acd8b4f 100644 --- a/app/composables/useNavigation.ts +++ b/app/composables/useNavigation.ts @@ -2,8 +2,9 @@ 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[] @@ -11,10 +12,10 @@ export interface NavGroup { link?: 'first-leaf' | 'section' /** * Explicit link target. Alone it makes a manual tab backed by an app route; together with `sections` - * the tab still owns those sections for its active state and the sidebar, only the link changes. + * 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 a manual tab active; defaults to `to`. Ignored when `sections` is set. */ + /** Path prefix that marks the tab active; defaults to `to`. */ activePath?: string /** Dropdown items for a manual tab. */ children?: NavGroupChild[] diff --git a/app/utils/navigation.ts b/app/utils/navigation.ts index 26125d8..b4bcbaf 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -1,6 +1,5 @@ import type { NavigationItem } from 'comark-content' import type { RouteLocationNormalized } from 'vue-router' -import type { NavGroup } from '../composables/useNavigation' /** Logical top-level segment of a path, ignoring the active version `base`. */ export function segmentOf(path: string, base: string): string { @@ -8,18 +7,24 @@ export function segmentOf(path: string, base: string): string { return rel.split('/').filter(Boolean)[0] ?? '' } +/** What decides whether a header tab is active. */ +export interface NavGroupTarget { + /** Top-level content sections the tab owns. */ + sections?: string[] + /** Explicit link target. */ + to?: string + /** Path prefix that marks the tab active; defaults to `to`. */ + activePath?: string +} + /** - * Whether a header tab is active on `path`. A tab with `sections` is active on any of them, whatever it - * links to; a manual tab is active under `activePath`, or under its own `to`. + * 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: Pick, - path: string, - base: string -): boolean { +export function isNavGroupActive(group: NavGroupTarget, path: string, base: string): boolean { const seg = segmentOf(path, base) - if (group.sections?.length) return group.sections.includes(seg) - // A manual target may carry a query or hash (`/play?example=basic`); only its path names a segment. + if (group.sections?.includes(seg)) return true + // A target may carry a query or hash (`/play?example=basic`); only its path names a segment. if (group.to) return seg === segmentOf((group.activePath ?? group.to).split(/[?#]/)[0]!, base) return false } diff --git a/test/navigation.test.ts b/test/navigation.test.ts index 5265cfb..235dd78 100644 --- a/test/navigation.test.ts +++ b/test/navigation.test.ts @@ -200,10 +200,18 @@ describe('isNavGroupActive', () => { expect(isNavGroupActive(group, '/plugins', '')).toBe(false) }) - it('keeps the sections for a tab that links elsewhere', () => { - const group = { to: '/docs', sections: ['getting-started', 'syntax'] } + 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, '/docs', '')).toBe(false) + 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) }) it('marks a manual tab active under its link or activePath', () => { From c5796420e71e88260ea81d11e4cbd2eafcb9b5cb Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 16 Sep 2026 09:31:44 +0200 Subject: [PATCH 4/8] fix(nav): honor activePath on a sections tab without to --- app/utils/navigation.ts | 4 ++-- test/navigation.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/utils/navigation.ts b/app/utils/navigation.ts index b4bcbaf..f115bd6 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -25,8 +25,8 @@ export function isNavGroupActive(group: NavGroupTarget, path: string, base: stri 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. - if (group.to) return seg === segmentOf((group.activePath ?? group.to).split(/[?#]/)[0]!, base) - return false + const target = group.activePath ?? group.to + return !!target && seg === segmentOf(target.split(/[?#]/)[0]!, base) } export function findPageHeadline( diff --git a/test/navigation.test.ts b/test/navigation.test.ts index 235dd78..14f99c4 100644 --- a/test/navigation.test.ts +++ b/test/navigation.test.ts @@ -212,6 +212,7 @@ describe('isNavGroupActive', () => { 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', () => { From 6e5004b8bf5511f3f3cd8c4cca393a22c546a499 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 16 Sep 2026 10:05:11 +0200 Subject: [PATCH 5/8] docs(nav): describe to alongside sections --- playground/content/2.writing/5.navigation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. From 98f2a6c5b0290d21a715eb21dd92b0bdfd9a6bf9 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 16 Sep 2026 10:06:17 +0200 Subject: [PATCH 6/8] chore(playground): link the Documentation tab and hero to /getting-started --- playground/app/app.config.ts | 2 +- playground/content/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/app/app.config.ts b/playground/app/app.config.ts index e3915be..e02ef18 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: '/getting-started', sections: ['getting-started', 'writing', 'concepts', 'deployment'] }, { label: 'Page', to: '/page' } ], }, diff --git a/playground/content/index.md b/playground/content/index.md index 9b45699..f50f132 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: /getting-started size: lg trailing-icon: i-lucide-arrow-right --- From 083bb1dfd6a56c76db96e2a76f699ddbf96d90a7 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 16 Sep 2026 10:14:07 +0200 Subject: [PATCH 7/8] chore(playground): reproduce a sections tab linking to a /docs redirect --- playground/app/app.config.ts | 2 +- playground/content/index.md | 2 +- playground/nuxt.config.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/playground/app/app.config.ts b/playground/app/app.config.ts index e02ef18..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', to: '/getting-started', 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/index.md b/playground/content/index.md index f50f132..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 + 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', From 179828e41fa47a723555a51f679e2d87342168cc Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 16 Sep 2026 10:20:08 +0200 Subject: [PATCH 8/8] refactor(nav): pick the sidebar group with isNavGroupActive --- app/composables/useNavigation.ts | 12 +----------- app/utils/navigation.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/app/composables/useNavigation.ts b/app/composables/useNavigation.ts index acd8b4f..696ee0b 100644 --- a/app/composables/useNavigation.ts +++ b/app/composables/useNavigation.ts @@ -6,17 +6,8 @@ import type { NavGroupTarget } from '../utils/navigation' 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 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 /** Dropdown items for a manual tab. */ children?: NavGroupChild[] } @@ -122,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 f115bd6..c52f3fc 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -7,11 +7,14 @@ export function segmentOf(path: string, base: string): string { return rel.split('/').filter(Boolean)[0] ?? '' } -/** What decides whether a header tab is active. */ +/** What decides where a header tab links and when it is active. */ export interface NavGroupTarget { - /** Top-level content sections the tab owns. */ + /** Top-level content sections grouped under this tab. */ sections?: string[] - /** Explicit link target. */ + /** + * 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