Merge branch 'main' into feat/docsearch-v5

pull/5402/head
Paul Jankowski 2 weeks ago committed by GitHub
commit 4d700cec32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -46,4 +46,4 @@ jobs:
cache: pnpm cache: pnpm
- run: pnpm install - run: pnpm install
- run: pnpm build - run: pnpm build
- run: npx pkg-pr-new publish --compact --no-template --pnpm - run: npx pkg-pr-new publish --compact --no-template --pnpm --packageManager=pnpm,npm,yarn

@ -65,6 +65,11 @@ const nav: DefaultTheme.Config['nav'] = [
] ]
} }
] ]
},
{
text: 'Active Match',
link: '/markdown-extensions/',
activeMatch: '^/home'
} }
] ]
@ -153,6 +158,23 @@ const sidebar: DefaultTheme.Config['sidebar'] = {
link: '/team-and-sponsors/home-no-markdown-styles' link: '/team-and-sponsors/home-no-markdown-styles'
} }
] ]
},
{
text: 'Sidebar Hash',
items: [
{
text: 'Overview',
link: '/sidebar-hash/'
},
{
text: 'Section One',
link: '/sidebar-hash/#section-one'
},
{
text: 'Section Two',
link: '/sidebar-hash/#section-two'
}
]
} }
], ],
'/multi-sidebar/': [ '/multi-sidebar/': [

@ -5,12 +5,13 @@ const props = defineProps<{
options: string[] options: string[]
defaultOption: string defaultOption: string
screenMenu?: boolean screenMenu?: boolean
menu?: boolean
}>() }>()
// reactivity isn't needed for props here // reactivity isn't needed for props here
const key = removeSpaces(`api-preference-${props.options.join('-')}`) const key = removeSpaces(`api-preference-${props.options.join('-')}`)
const name = key + (props.screenMenu ? '-screen-menu' : '') const name = key + (props.screenMenu ? '-screen-menu' : props.menu ? '-menu' : '')
const selected = useLocalStorage(key, () => props.defaultOption) const selected = useLocalStorage(key, () => props.defaultOption)
@ -25,8 +26,11 @@ function removeSpaces(str: string) {
</script> </script>
<template> <template>
<div class="VPApiPreference" :class="{ 'screen-menu': screenMenu }"> <div
<template v-for="option in optionsWithKeys" :key="option"> class="VPApiPreference"
:class="{ 'screen-menu': screenMenu, 'in-menu': menu }"
>
<template v-for="option in optionsWithKeys" :key="option.key">
<input <input
type="radio" type="radio"
:id="option.key" :id="option.key"
@ -42,10 +46,10 @@ function removeSpaces(str: string) {
<style scoped> <style scoped>
.VPApiPreference { .VPApiPreference {
display: flex; display: flex;
margin: 12px 0; margin: 0.75rem 0;
border: 1px solid var(--vp-c-border); border: 1px solid var(--vp-c-border);
border-radius: 6px; border-radius: 0.375rem;
font-size: 14px; font-size: 0.875rem;
color: var(--vp-c-text-1); color: var(--vp-c-text-1);
} }
@ -57,8 +61,13 @@ function removeSpaces(str: string) {
margin-bottom: 0; margin-bottom: 0;
} }
.VPApiPreference.in-menu {
margin: 0.5rem 0.75rem;
}
.VPApiPreference.screen-menu { .VPApiPreference.screen-menu {
margin: 12px 0 0 12px; margin: 0.75rem 0 0 0.75rem;
font-size: 1rem;
} }
.VPApiPreference input[type='radio'] { .VPApiPreference input[type='radio'] {
@ -69,10 +78,10 @@ function removeSpaces(str: string) {
.VPApiPreference label { .VPApiPreference label {
flex: 1; flex: 1;
margin: 2px; margin: 0.125rem;
padding: 4px 12px; padding: 0.25rem 0.75rem;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 0.25rem;
text-align: center; text-align: center;
} }

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import DefaultTheme from 'vitepress/theme' import DefaultTheme from 'vitepress/theme'
import HomeHeroCopy from './HomeHeroCopy.vue' import HomeHeroCopy from './HomeHeroCopy.vue'
const INSTALL_COMMAND = 'npx vitepress init' const INSTALL_COMMAND = 'npx vitepress init'

@ -49,14 +49,7 @@
width="18" width="18"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<rect <rect height="14" rx="2" ry="2" width="14" x="8" y="8" />
height="14"
rx="2"
ry="2"
width="14"
x="8"
y="8"
/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg> </svg>
<svg <svg

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import VPNavBarMenuGroup from 'vitepress/dist/client/theme-default/components/VPNavBarMenuGroup.vue' import VPNavMenuGroup from 'vitepress/dist/client/theme-default/components/VPNavMenuGroup.vue'
import VPNavScreenMenuGroup from 'vitepress/dist/client/theme-default/components/VPNavScreenMenuGroup.vue' import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
versions: { text: string; link: string }[] versions: { text: string; link: string }[]
screenMenu?: boolean screenMenu?: boolean
menu?: boolean
}>() }>()
const route = useRoute() const route = useRoute()
@ -26,15 +26,10 @@ const currentVersion = computed(() => {
</script> </script>
<template> <template>
<VPNavBarMenuGroup <VPNavMenuGroup
v-if="!screenMenu"
:item="{ text: currentVersion, items: versions }" :item="{ text: currentVersion, items: versions }"
class="VPNavVersion" :screen="screenMenu"
/> :menu="menu"
<VPNavScreenMenuGroup
v-else
:text="currentVersion"
:items="versions"
class="VPNavVersion" class="VPNavVersion"
/> />
</template> </template>

@ -1,7 +1,8 @@
import type { Theme } from 'vitepress' import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme' import DefaultTheme from 'vitepress/theme'
import CustomLayout from './components/CustomLayout.vue'
import ApiPreference from './components/ApiPreference.vue' import ApiPreference from './components/ApiPreference.vue'
import CustomLayout from './components/CustomLayout.vue'
import NavVersion from './components/NavVersion.vue' import NavVersion from './components/NavVersion.vue'
export default { export default {

@ -1,4 +1,5 @@
import fs from 'node:fs' import fs from 'node:fs'
import { defineLoader } from 'vitepress' import { defineLoader } from 'vitepress'
type Data = Record<string, boolean>[] type Data = Record<string, boolean>[]

@ -1,9 +1,10 @@
import { defineRoutes } from 'vitepress' import { defineRoutes } from 'vitepress'
import paths from './paths' import paths from './paths'
export default defineRoutes({ export default defineRoutes({
async paths(watchedFiles: string[]) { async paths(_watchedFiles: string[]) {
// console.log('watchedFiles', watchedFiles) // console.log('watchedFiles', _watchedFiles)
return paths return paths
}, },
watch: ['../data-loading/**/*.json'], watch: ['../data-loading/**/*.json'],

@ -123,13 +123,15 @@ describe('Table of Contents', () => {
}) })
describe('Custom Containers', () => { describe('Custom Containers', () => {
enum CustomBlocks { const CustomBlocks = {
Info = 'INFO', Info: 'INFO',
Tip = 'TIP', Tip: 'TIP',
Warning = 'WARNING', Warning: 'WARNING',
Danger = 'DANGER', Danger: 'DANGER',
Details = 'Details' Details: 'Details'
} } as const
type CustomBlocks = (typeof CustomBlocks)[keyof typeof CustomBlocks]
const classnameMap = { const classnameMap = {
[CustomBlocks.Info]: 'info', [CustomBlocks.Info]: 'info',

@ -16,7 +16,8 @@ describe('test multi sidebar sort root', () => {
'Multi Sidebar Test', 'Multi Sidebar Test',
'Dynamic Routes', 'Dynamic Routes',
'Markdown Extensions', 'Markdown Extensions',
'Team & Sponsors' 'Team & Sponsors',
'Sidebar Hash'
]) ])
}) })
}) })

@ -0,0 +1,239 @@
const ariaCurrent = (selector: string) =>
page.locator(selector).getAttribute('aria-current')
describe('navigation accessibility', () => {
beforeEach(async () => {
await page.setViewportSize({ width: 1280, height: 720 })
})
test('marks direct nav links to the current page', async () => {
await goto('/')
expect(await ariaCurrent('.VPNavBarMenuLink[href="/"]')).toBe('page')
await page.setViewportSize({ width: 375, height: 667 })
await page.locator('.VPNavBarHamburger').click()
expect(await ariaCurrent('.VPNavScreenMenuLink[href="/"]')).toBe('page')
})
test('marks nested nav links to the current page', async () => {
await goto('/home')
expect(await ariaCurrent('.VPMenuLink a[href="/home.html"]')).toBe('page')
expect(await ariaCurrent('.VPNavBarMenuLink[href="/"]')).toBeNull()
await page.setViewportSize({ width: 375, height: 667 })
await page.locator('.VPNavBarHamburger').click()
expect(
await ariaCurrent('.VPNavScreenMenuGroupLink[href="/home.html"]')
).toBe('page')
})
test('does not mark broad activeMatch links as current', async () => {
await goto('/home')
const sectionLink = page.locator(
'.VPNavBarMenuLink[href="/markdown-extensions/"]'
)
expect(await sectionLink.getAttribute('class')).toContain('active')
expect(await sectionLink.getAttribute('aria-current')).toBeNull()
})
test('marks only exact sidebar links, including fragments', async () => {
const overview = '.VPSidebarItem .link[href="/sidebar-hash/"]'
const sectionOne = '.VPSidebarItem .link[href="/sidebar-hash/#section-one"]'
const sectionTwo = '.VPSidebarItem .link[href="/sidebar-hash/#section-two"]'
await goto('/sidebar-hash/')
// wait for hydration to replace the hash-agnostic server-rendered state
await page.waitForFunction(
() => document.querySelectorAll('.VPSidebarItem.is-active').length === 1
)
expect(await ariaCurrent(overview)).toBe('page')
expect(await ariaCurrent(sectionOne)).toBeNull()
expect(await ariaCurrent(sectionTwo)).toBeNull()
await page.locator(sectionTwo).click()
await page.waitForSelector(`${sectionTwo}[aria-current="page"]`)
expect(await ariaCurrent(sectionOne)).toBeNull()
await page.locator(sectionOne).click()
await page.waitForSelector(`${sectionOne}[aria-current="page"]`)
expect(await ariaCurrent(sectionTwo)).toBeNull()
})
test('nav landmarks and controls have accessible names', async () => {
await goto('/')
expect(await page.locator('.VPNavBarMenu').getAttribute('aria-label')).toBe(
'Main Navigation'
)
await page.setViewportSize({ width: 375, height: 667 })
expect(
await page.locator('.VPNavBarHamburger').getAttribute('aria-label')
).toBe('Menu')
})
test('dropdown group is a keyboard-dismissible disclosure', async () => {
await goto('/')
const button = page.locator('.VPNavBarMenuGroup').first().locator('button')
expect(await button.getAttribute('aria-expanded')).toBe('false')
await button.click()
expect(await button.getAttribute('aria-expanded')).toBe('true')
await page.keyboard.press('Escape')
expect(await button.getAttribute('aria-expanded')).toBe('false')
// focus returned to the trigger
expect(
await page.evaluate(() => document.activeElement?.textContent)
).toContain('API Reference')
})
test('dropdown closes after navigating via a menu item', async () => {
await goto('/')
const group = page.locator('.VPNavBarMenuGroup').first()
await group.locator('button').click()
await group.locator('a[href="/home.html"]').click()
await page.waitForFunction(() => location.pathname.endsWith('/home.html'))
// the route watcher closes it on the post-navigation tick
await page.waitForSelector(
'.VPNavBarMenuGroup button[aria-expanded="false"]'
)
})
test('overflowing nav items move into the extra menu instead of clipping', async () => {
await goto('/')
// everything fits at 1280, so the ⋯ menu isn't rendered at all
expect(await page.locator('.VPNavBarExtra').count()).toBe(0)
// inflate the items so none of them can possibly fit
const style = await page.addStyleTag({
content: '.VPNavBarMenu .list > li > * { padding: 0 500px !important }'
})
await page.waitForSelector('.VPNavBarExtra')
// every control that stays in the bar remains fully within the viewport
// (no clipped/unreachable items — the failure mode of #2842)
expect(
await page.evaluate(() => {
const targets = [
...document.querySelectorAll('.VPNavBarMenu .list > li'),
document.querySelector('.VPNavBarSearch button'),
document.querySelector('.VPNavBarExtra > button')
].filter((el): el is HTMLElement => !!el)
return targets.every((el) => {
const rect = el.getBoundingClientRect()
return rect.left >= -1 && rect.right <= innerWidth + 1
})
})
).toBe(true)
// collapsed items and social links are reachable through the ⋯ menu
// (the scoped selector targets the moved "Home" item itself — the
// version-switcher component in the menu also links to "/")
await page.locator('.VPNavBarExtra > button').click()
await page.waitForSelector(
'.VPNavBarExtra .overflow-items > .VPMenuLink a[href="/"]'
)
await page.waitForSelector('.VPNavBarExtra .social-links')
// component items render menu-native: a titled group whose links are
// visible in place, not the screen accordion or a nested flyout
await page.waitForSelector(
'.VPNavBarExtra .overflow-items > .VPNavVersion .title'
)
expect(
await page.locator('.VPNavBarExtra .VPNavScreenMenuGroup').count()
).toBe(0)
// widening back restores the inline items and removes the ⋯ menu
await style.evaluate((el) => (el as HTMLStyleElement).remove())
await page.waitForSelector('.VPNavBarExtra', { state: 'detached' })
await page.waitForSelector('.VPNavBarMenuLink[href="/"]')
})
test('nav screen manages focus and inert state', async () => {
await page.setViewportSize({ width: 375, height: 667 })
await goto('/')
const hamburger = page.locator('.VPNavBarHamburger')
expect(await hamburger.getAttribute('aria-expanded')).toBe('false')
await hamburger.click()
await page.waitForSelector('#VPNavScreen')
expect(await hamburger.getAttribute('aria-expanded')).toBe('true')
// the covered page content is inert while the screen is open
expect(
await page.evaluate(() =>
document.getElementById('VPContent')!.hasAttribute('inert')
)
).toBe(true)
await page.keyboard.press('Escape')
await page.waitForSelector('#VPNavScreen', { state: 'detached' })
expect(
await page.evaluate(() =>
document.getElementById('VPContent')!.hasAttribute('inert')
)
).toBe(false)
// focus returned to the hamburger
expect(
await page.evaluate(() => document.activeElement?.className)
).toContain('VPNavBarHamburger')
})
test('screen menu group is a real disclosure', async () => {
await page.setViewportSize({ width: 375, height: 667 })
await goto('/')
await page.locator('.VPNavBarHamburger').click()
const group = page.locator('.VPNavScreenMenuGroup').first()
const button = group.locator('button').first()
expect(await button.getAttribute('aria-expanded')).toBe('false')
// collapsed content is hidden from view and the tab order
expect(await group.locator('a[href="/home.html"]').isVisible()).toBe(false)
await button.click()
expect(await button.getAttribute('aria-expanded')).toBe('true')
await page.waitForSelector('.VPNavScreenMenuGroup a[href="/home.html"]')
// aria-controls points at the panel it toggles
const controls = await button.getAttribute('aria-controls')
expect(await group.locator(`ul[id="${controls}"]`).count()).toBe(1)
})
test.runIf(process.env.VITE_TEST_BUILD)(
'omits aria-current for fragment links in server-rendered HTML',
async () => {
const response = await page.request.get(
`http://localhost:${process.env['PORT']}/sidebar-hash/`
)
const anchors = (
(await response.text()).match(/<a\b[^>]*>/g) ?? []
).filter((anchor) => anchor.includes('/sidebar-hash/'))
expect(
anchors.filter((anchor) => anchor.includes('#section-')).length
).toBeGreaterThanOrEqual(2)
expect(
anchors.filter((anchor) => anchor.includes('aria-current'))
).toEqual([expect.stringContaining('href="/sidebar-hash/"')])
}
)
})

@ -0,0 +1,11 @@
# Sidebar Hash
A page whose sidebar entries point at fragments of the same page.
## Section One
Content for the first section.
## Section Two
Content for the second section.

@ -0,0 +1,36 @@
describe('sidebar', () => {
beforeAll(async () => {
await goto('/frontmatter/multiple-levels-outline')
})
test('collapsible group renders a heading and a single toggle button', async () => {
const group = page.locator('.VPSidebarItem.level-0.collapsible').first()
const caret = group.locator('.caret').first()
expect(await page.locator('.VPSidebarItem [role="button"]').count()).toBe(0)
expect(await caret.evaluate((el) => el.tagName)).toBe('BUTTON')
expect(await caret.getAttribute('aria-expanded')).toBe('true')
})
test('group toggles with keyboard, caret and heading', async () => {
const group = page.locator('.VPSidebarItem.level-0.collapsible').first()
const caret = group.locator('.caret').first()
const isCollapsed = () =>
group.evaluate((el) => el.classList.contains('collapsed'))
await caret.focus()
await page.keyboard.press('Enter')
expect(await isCollapsed()).toBe(true)
expect(await caret.getAttribute('aria-expanded')).toBe('false')
await page.keyboard.press('Space')
expect(await isCollapsed()).toBe(false)
expect(await caret.getAttribute('aria-expanded')).toBe('true')
await caret.click()
expect(await isCollapsed()).toBe(true)
await group.locator('.text').first().click()
expect(await isCollapsed()).toBe(false)
})
})

@ -1,5 +1,6 @@
import getPort from 'get-port'
import type { Server } from 'node:net' import type { Server } from 'node:net'
import getPort from 'get-port'
import { chromium, type BrowserServer } from 'playwright-chromium' import { chromium, type BrowserServer } from 'playwright-chromium'
import type { ViteDevServer } from 'vite' import type { ViteDevServer } from 'vite'
import { build, createServer, serve } from 'vitepress' import { build, createServer, serve } from 'vitepress'

@ -1,8 +1,9 @@
import getPort from 'get-port'
import { nanoid } from 'nanoid'
import { rm } from 'node:fs/promises' import { rm } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import getPort from 'get-port'
import { nanoid } from 'nanoid'
import { chromium } from 'playwright-chromium' import { chromium } from 'playwright-chromium'
import { createServer, scaffold, ScaffoldThemeType } from 'vitepress' import { createServer, scaffold, ScaffoldThemeType } from 'vitepress'

@ -0,0 +1,135 @@
import {
computeNavFit,
type NavFitInput
} from 'client/theme-default/composables/nav-overflow'
function fit(input: Partial<NavFitInput>) {
return computeNavFit({
itemWidths: [],
translations: null,
appearance: null,
socialLinks: null,
available: 0,
extraWidth: 40,
...input
})
}
describe('client/theme-default/composables/nav-overflow', () => {
describe('computeNavFit', () => {
test('keeps everything when it fits', () => {
expect(
fit({
itemWidths: [100, 100],
translations: 50,
appearance: 60,
socialLinks: 90,
available: 400
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: true
})
})
test('collapses social links first', () => {
expect(
fit({
itemWidths: [100, 100],
translations: 50,
appearance: 60,
socialLinks: 90,
available: 390
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: false
})
})
test('collapses the cluster cascade in order', () => {
// items (200) + translations (50) fit in the 260 budget after
// reserving the extra button, appearance (60) does not — social links
// must follow appearance out even though they'd fit alone
expect(
fit({
itemWidths: [100, 100],
translations: 50,
appearance: 60,
socialLinks: 5,
available: 300
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: false,
socialLinks: false
})
})
test('collapses menu items right-to-left after the cluster', () => {
expect(
fit({
itemWidths: [100, 100, 100],
translations: 50,
available: 250
})
).toEqual({
visibleItemCount: 2,
translations: false,
appearance: true,
socialLinks: true
})
})
test('ignores unconfigured cluster units', () => {
expect(
fit({
itemWidths: [100],
socialLinks: 90,
available: 150
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: false
})
})
test('collapses everything when nothing fits', () => {
expect(
fit({
itemWidths: [100, 100],
translations: 50,
available: 30
})
).toEqual({
visibleItemCount: 0,
translations: false,
// unconfigured units just stay "not collapsed"
appearance: true,
socialLinks: true
})
})
test('a lone overwide item still collapses instead of clipping', () => {
expect(fit({ itemWidths: [500], available: 400 }).visibleItemCount).toBe(
0
)
})
test('handles an empty nav', () => {
expect(fit({ socialLinks: 90, appearance: 60, available: 80 })).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: false,
socialLinks: false
})
})
})
})

@ -1,9 +1,10 @@
import { resolveConfig } from 'node/config'
import { createContentLoader } from 'node/contentLoader'
import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { resolveConfig } from 'node/config'
import { createContentLoader } from 'node/contentLoader'
describe('node/contentLoader', () => { describe('node/contentLoader', () => {
let root: string | undefined let root: string | undefined

@ -1,6 +1,7 @@
import path from 'node:path' import path from 'node:path'
import { MarkdownItAsync } from 'markdown-it-async'
import { attrs as attrsPlugin } from '@mdit/plugin-attrs' import { attrs as attrsPlugin } from '@mdit/plugin-attrs'
import { MarkdownItAsync } from 'markdown-it-async'
import { imagePlugin, type Options } from 'node/markdown/plugins/image' import { imagePlugin, type Options } from 'node/markdown/plugins/image'
const srcDir = path.resolve(import.meta.dirname, '../../../../e2e') const srcDir = path.resolve(import.meta.dirname, '../../../../e2e')

@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { import {
createMarkdownRenderer, createMarkdownRenderer,
disposeMdItInstance, disposeMdItInstance,

@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { import {
createMarkdownRenderer, createMarkdownRenderer,
disposeMdItInstance, disposeMdItInstance,

@ -1,9 +1,10 @@
import { resolveConfig } from 'node/config'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { resolveConfig } from 'node/config'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
describe('node/markdownToVue', () => { describe('node/markdownToVue', () => {
let root: string | undefined let root: string | undefined

@ -1,11 +1,12 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { resolveConfig } from 'node/config' import { resolveConfig } from 'node/config'
import { disposeMdItInstance } from 'node/markdown/markdown' import { disposeMdItInstance } from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue' import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { localSearchPlugin } from 'node/plugins/localSearchPlugin' import { localSearchPlugin } from 'node/plugins/localSearchPlugin'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
describe('node/plugins/localSearchPlugin', () => { describe('node/plugins/localSearchPlugin', () => {
let root: string | undefined let root: string | undefined

@ -1,6 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { readFile, readTextFile, readTextFileSync } from 'node/utils/fs' import { readFile, readTextFile, readTextFileSync } from 'node/utils/fs'
describe('node/utils/fs', () => { describe('node/utils/fs', () => {

@ -0,0 +1,56 @@
import { mergeHead, type HeadConfig } from 'shared/shared'
describe('shared/shared', () => {
describe('mergeHead', () => {
test('replaces meta tags with the same key in place', () => {
expect(
mergeHead(
[
['meta', { property: 'og:image', content: '/site.png' }],
['meta', { name: 'keywords', content: 'site' }]
],
[['meta', { content: '/page.png', property: 'og:image' }]]
)
).toEqual([
['meta', { content: '/page.png', property: 'og:image' }],
['meta', { name: 'keywords', content: 'site' }]
])
})
test('ignores content when keying meta tags', () => {
const head: HeadConfig[] = [
['meta', { content: 'a', name: 'name1' }],
['meta', { content: 'a', name: 'name2' }]
]
expect(mergeHead(head)).toEqual(head)
})
test('keys any element by id regardless of attribute order', () => {
expect(
mergeHead(
[
['meta', { name: 'author', content: 'a', id: 'author-a' }],
['meta', { name: 'author', content: 'b', id: 'author-b' }],
['script', { id: 'sw' }, 'old']
],
[
['meta', { id: 'author-a', name: 'author', content: 'c' }],
['script', { id: 'sw' }, 'new']
]
)
).toEqual([
['meta', { id: 'author-a', name: 'author', content: 'c' }],
['meta', { name: 'author', content: 'b', id: 'author-b' }],
['script', { id: 'sw' }, 'new']
])
})
test('appends elements without a key', () => {
const head: HeadConfig[] = [
['link', { rel: 'stylesheet', href: '/a.css' }],
['link', { rel: 'stylesheet', href: '/a.css' }]
]
expect(mergeHead(head, head)).toEqual([...head, ...head])
})
})
})

@ -1,6 +1,7 @@
import vue from '@vitejs/plugin-vue'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
const dir = dirname(fileURLToPath(import.meta.url)) const dir = dirname(fileURLToPath(import.meta.url))

@ -9,6 +9,7 @@ import {
localIconLoader localIconLoader
} from 'vitepress-plugin-group-icons' } from 'vitepress-plugin-group-icons'
import llmstxt from 'vitepress-plugin-llms' import llmstxt from 'vitepress-plugin-llms'
import { markdown as esMarkdown } from '../es/config.ts' import { markdown as esMarkdown } from '../es/config.ts'
import { markdown as faMarkdown } from '../fa/config.ts' import { markdown as faMarkdown } from '../fa/config.ts'
import { markdown as jaMarkdown } from '../ja/config.ts' import { markdown as jaMarkdown } from '../ja/config.ts'

@ -1,6 +1,7 @@
/// <reference types="vitepress/client" /> /// <reference types="vitepress/client" />
import Theme from 'vitepress/theme' import Theme from 'vitepress/theme'
import 'virtual:group-icons.css' import 'virtual:group-icons.css'
import './styles.css' import './styles.css'

@ -40,6 +40,46 @@ export default DefaultTheme
See [default theme CSS variables](https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css) that can be overridden. See [default theme CSS variables](https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css) that can be overridden.
### Navbar
The navbar draws a single background surface controlled by CSS variables, so its look can be changed without touching component internals:
```css
:root {
/* bar height and background */
--vp-nav-height: 4rem;
--vp-nav-bg-color: var(--vp-c-bg);
/* background while on top of the home page (unscrolled);
set to var(--vp-nav-bg-color) to opt out of the transparent treatment */
--vp-nav-home-bg-color: transparent;
/* filter applied to the content behind the bar */
--vp-nav-backdrop-filter: none;
/* the bar's bottom rule and the mobile menu background */
--vp-nav-divider-color: var(--vp-c-gutter);
--vp-nav-screen-bg-color: var(--vp-c-bg);
}
```
For example, a frosted-glass navbar:
```css
:root {
--vp-nav-bg-color: color-mix(in srgb, var(--vp-c-bg) 65%, transparent);
--vp-nav-backdrop-filter: saturate(180%) blur(8px);
}
```
The same treatment carries over to the local nav: `--vp-local-nav-bg-color` follows the navbar surface color by default, and where the two bars meet they share a single blurred surface, so the glass stays continuous across them.
::: warning
`backdrop-filter` has a measurable scroll performance cost, especially on large or high-DPI screens. When using a translucent bar, also check text contrast over your page content. Safari 17 and earlier don't apply variable-driven backdrop filters, so they show the translucent color without the blur.
:::
When the nav items don't fit the available width, they move into the `⋯` menu at the end of the navbar instead of being clipped, starting with the social links, the appearance switch and the locale switcher, followed by the nav items right-to-left. Its button label can be localized with [`extraMenuLabel`](../reference/default-theme-config#extramenulabel).
## Using Different Fonts ## Using Different Fonts
VitePress uses [Inter](https://rsms.me/inter/) as the default font, and will include the fonts in the build output. The font is also auto preloaded in production. However, this may not be desirable if you want to use a different main font. VitePress uses [Inter](https://rsms.me/inter/) as the default font, and will include the fonts in the build output. The font is also auto preloaded in production. However, this may not be desirable if you want to use a different main font.

@ -470,6 +470,27 @@ Can be used to customize the label of the return to top button. This label is on
Can be used to customize the aria-label of the language toggle button in navbar. This is only used if you're using [i18n](../guide/i18n). Can be used to customize the aria-label of the language toggle button in navbar. This is only used if you're using [i18n](../guide/i18n).
## navMenuLabel
- Type: `string`
- Default: `Main Navigation`
Can be used to customize the accessible label of the main navigation landmarks (the navbar menu and the mobile menu).
## mobileMenuLabel
- Type: `string`
- Default: `Menu`
Can be used to customize the aria-label of the mobile menu (hamburger) button.
## extraMenuLabel
- Type: `string`
- Default: `More options`
Can be used to customize the aria-label of the `⋯` menu button in the navbar. That menu collects the nav items and controls that don't fit in the bar at the current viewport size.
## skipToContentLabel ## skipToContentLabel
- Type: `string` - Type: `string`

@ -216,5 +216,6 @@ export default {
Your component will be rendered in the navigation bar. VitePress will provide the following additional props to the component: Your component will be rendered in the navigation bar. VitePress will provide the following additional props to the component:
- `screenMenu`: an optional boolean indicating whether the component is inside mobile navigation menu - `screenMenu`: an optional boolean indicating whether the component is inside mobile navigation menu
- `menu`: an optional boolean indicating whether the component is inside a dropdown panel — for example, the `⋯` menu that nav items collapse into when they don't fit the bar. In both these contexts, render a flat list instead of a floating flyout, which would end up nested inside the panel
You can check an example in the e2e tests [here](https://github.com/vuejs/vitepress/tree/main/__tests__/e2e/.vitepress). You can check an example in the e2e tests [here](https://github.com/vuejs/vitepress/tree/main/__tests__/e2e/.vitepress).

@ -27,10 +27,11 @@ Example result:
Alternatively, you can use [Algolia DocSearch](#algolia-search) or some community plugins like: Alternatively, you can use [Algolia DocSearch](#algolia-search) or some community plugins like:
- <https://www.npmjs.com/package/vitepress-plugin-search> - <https://npmx.dev/package/vitepress-plugin-pagefind>
- <https://www.npmjs.com/package/vitepress-plugin-pagefind> - <https://npmx.dev/package/vitepress-plugin-typesense>
- <https://www.npmjs.com/package/@orama/plugin-vitepress> - <https://npmx.dev/package/vitepress-plugin-cloudflare-ai-search>
- <https://www.npmjs.com/package/vitepress-plugin-typesense>
<!-- - <https://npmx.dev/package/@orama/plugin-vitepress> -- replace with zbsearch one when published -->
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -63,7 +63,7 @@ description: VitePress
- Type: `HeadConfig[]` - Type: `HeadConfig[]`
Specify extra head tags to be injected for the current page. Will be appended after head tags injected by site-level config. Specify extra head tags to be injected for the current page. They are [merged](./site-config#head) with the head tags injected by site-level config.
```yaml ```yaml
--- ---

@ -248,6 +248,13 @@ type HeadConfig =
| [string, Record<string, string>, string] | [string, Record<string, string>, string]
``` ```
Head entries from the site config, [locale config](../guide/i18n), [directory-level config](#directory-level-overrides), [frontmatter](./frontmatter-config#head) and [`transformHead`](#transformhead) are merged in that order. A later entry replaces an earlier one with the same key instead of being appended:
- Any element with an `id` attribute is keyed by its `id`.
- A `meta` element without an `id` is keyed by its first attribute other than `content` (e.g. `name`, `property`, `http-equiv`) and that attribute's value.
Other elements are never deduplicated. To render multiple `meta` tags that would share a key, like several `<meta name="author">`, give each of them a unique `id`.
#### Example: Adding a favicon #### Example: Adding a favicon
```ts ```ts

@ -25,7 +25,7 @@ Resultado de ejemplo:
![captura de pantalla del modo de búsqueda](/search.png) ![captura de pantalla del modo de búsqueda](/search.png)
Alternativamente, puedes usar [Algolia DocSearch](#algolia-search) o algunos complementos comunitarios como <https://www.npmjs.com/package/vitepress-plugin-search> o <https://www.npmjs.com/package/vitepress-plugin-pagefind>. Alternativamente, puedes usar [Algolia DocSearch](#algolia-search) o algunos complementos comunitarios como <https://www.npmjs.com/package/vitepress-plugin-search>, <https://www.npmjs.com/package/vitepress-plugin-pagefind> o <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>.
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -25,7 +25,7 @@ export default defineConfig({
![تصویر نمایشی از مودال جستجو](/search.png) ![تصویر نمایشی از مودال جستجو](/search.png)
همچنین، می‌توانید از [Algolia DocSearch](#algolia-search) یا برخی افزونه‌های جامعه‌ای مانند <https://www.npmjs.com/package/vitepress-plugin-search> یا <https://www.npmjs.com/package/vitepress-plugin-pagefind> استفاده کنید. همچنین، می‌توانید از [Algolia DocSearch](#algolia-search) یا برخی افزونه‌های جامعه‌ای مانند <https://www.npmjs.com/package/vitepress-plugin-search>، <https://www.npmjs.com/package/vitepress-plugin-pagefind> یا <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search> استفاده کنید.
### بین‌المللی‌سازی {#local-search-i18n} ### بین‌المللی‌سازی {#local-search-i18n}

@ -31,6 +31,7 @@ export default defineConfig({
- <https://www.npmjs.com/package/vitepress-plugin-pagefind> - <https://www.npmjs.com/package/vitepress-plugin-pagefind>
- <https://www.npmjs.com/package/@orama/plugin-vitepress> - <https://www.npmjs.com/package/@orama/plugin-vitepress>
- <https://www.npmjs.com/package/vitepress-plugin-typesense> - <https://www.npmjs.com/package/vitepress-plugin-typesense>
- <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -25,7 +25,7 @@ export default defineConfig({
![검색 모달의 스크린샷](/search.png) ![검색 모달의 스크린샷](/search.png)
대안으로 [Algolia DocSearch](#algolia-search), <https://www.npmjs.com/package/vitepress-plugin-search>, <https://www.npmjs.com/package/vitepress-plugin-pagefind>와 같은 커뮤니티 플러그인을 사용할 수도 있습니다. 대안으로 [Algolia DocSearch](#algolia-search), <https://www.npmjs.com/package/vitepress-plugin-search>, <https://www.npmjs.com/package/vitepress-plugin-pagefind>, <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>와 같은 커뮤니티 플러그인을 사용할 수도 있습니다.
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -25,7 +25,7 @@ Exemplo de resultado:
![captura de tela do modal de pesquisa](/search.png) ![captura de tela do modal de pesquisa](/search.png)
Alternativamente, você pode usar [Algolia DocSearch](#algolia-search) ou alguns plugins da comunidade como <https://www.npmjs.com/package/vitepress-plugin-search> ou <https://www.npmjs.com/package/vitepress-plugin-pagefind>. Alternativamente, você pode usar [Algolia DocSearch](#algolia-search) ou alguns plugins da comunidade como <https://www.npmjs.com/package/vitepress-plugin-search>, <https://www.npmjs.com/package/vitepress-plugin-pagefind> ou <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>.
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -31,6 +31,7 @@ export default defineConfig({
- <https://www.npmjs.com/package/vitepress-plugin-pagefind> - <https://www.npmjs.com/package/vitepress-plugin-pagefind>
- <https://www.npmjs.com/package/@orama/plugin-vitepress> - <https://www.npmjs.com/package/@orama/plugin-vitepress>
- <https://www.npmjs.com/package/vitepress-plugin-typesense> - <https://www.npmjs.com/package/vitepress-plugin-typesense>
- <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -779,7 +779,7 @@ export default config
<!--@@include: ./parts/basics.md--> <!--@@include: ./parts/basics.md-->
``` ```
**Part file** (`parts/basics.md`) **部分文件** (`parts/basics.md`)
```md ```md
Some getting started stuff. Some getting started stuff.
@ -815,7 +815,7 @@ Can be created using `.foorc.json`.
<!--@@include: ./parts/basics.md{3,}--> <!--@@include: ./parts/basics.md{3,}-->
``` ```
**Part file** (`parts/basics.md`) **部分文件** (`parts/basics.md`)
```md ```md
Some getting started stuff. Some getting started stuff.

@ -25,7 +25,7 @@ export default defineConfig({
![搜索弹窗截图](/search.png) ![搜索弹窗截图](/search.png)
或者,你可以使用 [Algolia DocSearch](#algolia-search) 或一些社区插件,例如:<https://www.npmjs.com/package/vitepress-plugin-search> 或者 <https://www.npmjs.com/package/vitepress-plugin-pagefind> 或者,你可以使用 [Algolia DocSearch](#algolia-search) 或一些社区插件,例如:<https://www.npmjs.com/package/vitepress-plugin-search><https://www.npmjs.com/package/vitepress-plugin-pagefind> 或者 <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>
### i18n {#local-search-i18n} ### i18n {#local-search-i18n}

@ -52,19 +52,12 @@
"lib" "lib"
], ],
"scripts": { "scripts": {
"clean": "node -e \"require('node:fs').rmSync('./dist',{recursive:!0,force:!0,maxRetries:10})\"", "dev": "tsdown --watch --sourcemap",
"dev": "pnpm clean && pnpm dev:shared && pnpm dev:start", "build": "tsdown && pnpm typecheck && node scripts/genWebTypes.ts && pnpm build:check",
"dev:start": "pnpm --stream '/^dev:(client|node|watch)$/'", "build:check": "publint && attw --pack . --profile esm-only",
"dev:client": "tsc --sourcemap -w --preserveWatchOutput -p src/client", "typecheck": "tsc -p tsconfig.shared.json && vue-tsc -p tsconfig.client.json && tsc -p tsconfig.node.json",
"dev:node": "DEV=true pnpm build:node -w",
"dev:shared": "node scripts/copyShared.ts",
"dev:watch": "node scripts/watchAndCopy.ts",
"build": "pnpm build:prepare && pnpm build:client && pnpm build:node && node scripts/genWebTypes.ts",
"build:prepare": "pnpm clean && node scripts/copyShared.ts",
"build:client": "vue-tsc --noEmit -p src/client && tsc -p src/client && node scripts/copyClient.ts",
"build:node": "tsc -p src/node --noEmit && rollup --config rollup.config.ts",
"test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init)$/'", "test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init)$/'",
"test:types": "tsc -p __tests__/unit && tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs", "test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs",
"test:unit": "vitest run -r __tests__/unit", "test:unit": "vitest run -r __tests__/unit",
"test:unit:watch": "vitest -r __tests__/unit", "test:unit:watch": "vitest -r __tests__/unit",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build", "test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",
@ -132,13 +125,9 @@
"@mdit/plugin-emoji": "^1.1.1", "@mdit/plugin-emoji": "^1.1.1",
"@mdit/plugin-footnote": "^1.0.2", "@mdit/plugin-footnote": "^1.0.2",
"@mdit/plugin-tasklist": "^1.0.2", "@mdit/plugin-tasklist": "^1.0.2",
"@arethetypeswrong/cli": "^0.18.5",
"@polka/compression": "^1.0.0-next.28", "@polka/compression": "^1.0.0-next.28",
"@rolldown/pluginutils": "^1.0.1", "@rolldown/pluginutils": "^1.0.1",
"@rollup/plugin-alias": "^6.0.0",
"@rollup/plugin-commonjs": "^29.0.3",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@types/cross-spawn": "^6.0.6", "@types/cross-spawn": "^6.0.6",
"@types/lodash.template": "^4.5.3", "@types/lodash.template": "^4.5.3",
"@types/mark.js": "^8.11.12", "@types/mark.js": "^8.11.12",
@ -146,11 +135,11 @@
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@types/picomatch": "^4.0.3", "@types/picomatch": "^4.0.3",
"@types/semver": "^7.8.0", "@types/semver": "^7.8.0",
"chokidar": "^5.0.0", "@volar/typescript": "^2.4.28",
"@vue/language-core": "^3.3.11",
"conventional-changelog": "^8.1.1", "conventional-changelog": "^8.1.1",
"conventional-changelog-angular": "^9.2.1", "conventional-changelog-angular": "^9.2.1",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"esbuild": "^0.27.7",
"get-port": "^7.2.0", "get-port": "^7.2.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"image-size": "^2.0.2", "image-size": "^2.0.2",
@ -175,18 +164,18 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-selector-parser": "^7.1.5", "postcss-selector-parser": "^7.1.5",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"punycode": "^2.3.1", "publint": "^0.3.24",
"rollup": "^4.62.4", "rolldown": "^1.2.5",
"rollup-plugin-dts": "6.1.1",
"rollup-plugin-esbuild": "^6.2.1",
"semver": "^7.8.5", "semver": "^7.8.5",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.13.1",
"sirv": "^3.0.2", "sirv": "^3.0.2",
"sitemap": "^9.0.1", "sitemap": "^9.0.1",
"tinyglobby": "^0.2.17", "tinyglobby": "^0.2.17",
"typescript": "^5.9.3", "tsdown": "^0.22.14",
"typescript": "^6.0.3",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vue-tsc": "^3.3.9", "vue-sfc-transformer": "^0.2.5",
"vue-tsc": "^3.3.11",
"wait-on": "^9.1.0" "wait-on": "^9.1.0"
}, },
"peerDependencies": { "peerDependencies": {

@ -0,0 +1,18 @@
diff --git a/dist/rolldown.mjs b/dist/rolldown.mjs
index 7e04d96c703656b81220193b1f01426778b6a42f..26e96378fc0ad985237302e673ed0ab07674bf41 100644
--- a/dist/rolldown.mjs
+++ b/dist/rolldown.mjs
@@ -196,10 +196,11 @@ function resolveCache(options) {
async function transpileScript(code, filename = "__sfc.ts") {
const result = await transform(filename, code, {
lang: "ts",
- sourcemap: false
+ sourcemap: false,
+ typescript: { onlyRemoveTypeImports: true }
});
if (result.errors.length) throw new AggregateError(result.errors, `[vue-sfc-transformer] failed to transpile script in ${filename}`);
- return result.code ?? code;
+ return (result.code ?? code).replace(/\n?export \{\};?[\s\n]*$/, "");
}
function vueSfcPlugin(pluginOptions) {
const cwd = pluginOptions.cwd ?? process.cwd();

File diff suppressed because it is too large Load Diff

@ -3,7 +3,6 @@ packages:
- __tests__/* - __tests__/*
allowBuilds: allowBuilds:
esbuild: true
playwright-chromium: true playwright-chromium: true
simple-git-hooks: true simple-git-hooks: true
@ -13,6 +12,12 @@ ignoreWorkspaceRootCheck: true
minimumReleaseAge: 1440 minimumReleaseAge: 1440
overrides:
esbuild: '-'
patchedDependencies:
vue-sfc-transformer: patches/vue-sfc-transformer.patch
shellEmulator: true shellEmulator: true
strictPeerDependencies: true strictPeerDependencies: true

@ -1,113 +0,0 @@
import alias from '@rollup/plugin-alias'
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import { rm } from 'node:fs/promises'
import { builtinModules } from 'node:module'
import { fileURLToPath } from 'node:url'
import { type RollupOptions, defineConfig } from 'rollup'
import dts from 'rollup-plugin-dts'
import esbuild from 'rollup-plugin-esbuild'
import pkg from './package.json' with { type: 'json' }
const DEV = !!process.env.DEV
const PROD = !DEV
const external = [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies),
...builtinModules.flatMap((m) =>
m.includes('punycode') ? [] : [m, `node:${m}`]
)
]
const plugins = [
alias({ entries: { 'readable-stream': 'stream' } }),
replace({
// polyfill broken browser check from bundled deps
'navigator.userAgentData': 'undefined',
'navigator.userAgent': 'undefined',
preventAssignment: true
}),
commonjs(),
nodeResolve({ preferBuiltins: false }),
esbuild({ target: 'node22' }),
json()
]
const esmBuild: RollupOptions = {
input: ['src/node/index.ts', 'src/node/cli.ts'],
output: {
format: 'esm',
entryFileNames: `[name].js`,
chunkFileNames: 'chunk-[hash].js',
dir: 'dist/node',
sourcemap: DEV
},
external,
plugins,
onwarn(warning, warn) {
if (warning.code !== 'EVAL') warn(warning)
}
}
// keep .d.ts files under the repo root (e.g. types/*) external so module
// augmentations in the bundle still target the same files users reference.
// compared on normalized resolved paths so this works regardless of the
// checkout location, path separators, or drive-letter casing.
const normalizePath = (id: string): string => {
const normalized = id.replaceAll('\\', '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
const root = normalizePath(fileURLToPath(new URL('.', import.meta.url)))
const typesExternal = (id: string): boolean => {
if (external.includes(id) || /^markdown-it(?:\/|$)/.test(id)) return true
const normalized = normalizePath(id)
return (
normalized.endsWith('.d.ts') &&
normalized.startsWith(root) &&
!normalized.startsWith(`${root}dist/`) &&
!normalized.startsWith(`${root}node_modules/`)
)
}
const dtsNode = dts({
respectExternal: true,
tsconfig: 'src/node/tsconfig.json',
compilerOptions: { preserveSymlinks: false }
})
const nodeTypes: RollupOptions = {
input: 'src/node/index.ts',
output: {
format: 'esm',
file: 'dist/node/index.d.ts'
},
external: typesExternal,
plugins: [dtsNode]
}
const clientTypes: RollupOptions = {
input: 'dist/client-types/index.d.ts',
output: {
format: 'esm',
file: 'dist/client/index.d.ts'
},
external: typesExternal,
plugins: [
dts({ respectExternal: true }),
{
name: 'cleanup',
async closeBundle() {
if (PROD) {
await rm('dist/client-types', { recursive: true })
}
}
}
]
}
export default defineConfig([esmBuild, nodeTypes, clientTypes])

@ -1,11 +0,0 @@
import { cp } from 'node:fs/promises'
import { globSync } from 'tinyglobby'
function toDest(file: string) {
return file.replace(/^src\//, 'dist/')
}
globSync(['src/client/**']).forEach((file) => {
if (/(\.ts|tsconfig\.json)$/.test(file)) return
cp(file, toDest(file))
})

@ -1,9 +0,0 @@
import { cp } from 'node:fs/promises'
import { globSync } from 'tinyglobby'
globSync(['src/shared/**/*.ts']).forEach(async (file) => {
await Promise.all([
cp(file, file.replace(/^src\/shared\//, 'src/node/')),
cp(file, file.replace(/^src\/shared\//, 'src/client/'))
])
})

@ -1,16 +1,15 @@
import { spawn } from 'cross-spawn'
import type { SpawnOptions } from 'node:child_process' import type { SpawnOptions } from 'node:child_process'
import { once } from 'node:events' import { once } from 'node:events'
import fs from 'node:fs' import fs from 'node:fs'
import { createRequire } from 'node:module'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import * as prompts from '@clack/prompts' import * as prompts from '@clack/prompts'
import { spawn } from 'cross-spawn'
import semver from 'semver' import semver from 'semver'
const { version: currentVersion } = createRequire(import.meta.url)( import { version as currentVersion } from '../package.json' with { type: 'json' }
'../package.json'
)
const { inc: _inc, valid } = semver const { inc: _inc, valid } = semver
const versionIncrements = ['patch', 'minor', 'major'] as const const versionIncrements = ['patch', 'minor', 'major'] as const

@ -1,37 +0,0 @@
import { watch } from 'chokidar'
import { cp, rm } from 'node:fs/promises'
import { normalizePath } from 'vite'
function toClientAndNode(method: 'copy' | 'remove', file: string) {
file = normalizePath(file)
if (method === 'copy') {
cp(file, file.replace(/^src\/shared\//, 'src/node/'))
cp(file, file.replace(/^src\/shared\//, 'src/client/'))
} else if (method === 'remove') {
rm(file.replace(/^src\/shared\//, 'src/node/'), { force: true })
rm(file.replace(/^src\/shared\//, 'src/client/'), { force: true })
}
}
function toDist(file: string) {
return normalizePath(file).replace(/^src\//, 'dist/')
}
// copy shared files to the client and node directory whenever they change.
watch('src/shared', {
ignored: (path, stats) => !!stats?.isFile() && !path.endsWith('.ts')
})
.on('change', (file) => toClientAndNode('copy', file))
.on('add', (file) => toClientAndNode('copy', file))
.on('unlink', (file) => toClientAndNode('remove', file))
// copy non ts files, such as an html or css, to the dist directory whenever
// they change.
watch('src/client', {
ignored: (path, stats) =>
!!stats?.isFile() &&
(path.endsWith('.ts') || path.endsWith('tsconfig.json'))
})
.on('change', (file) => cp(file, toDist(file)))
.on('add', (file) => cp(file, toDist(file)))
.on('unlink', (file) => rm(toDist(file), { force: true }))

@ -0,0 +1,13 @@
// Cross-environment globals that environment-neutral code may use, declared
// merge-compatibly with lib.dom and @types/node (interface merging plus an
// identically named var). Loaded only by projects without a DOM lib; keep
// members to what shared code actually touches.
interface Console {
debug(...data: unknown[]): void
warn(...data: unknown[]): void
}
declare var console: Console
interface Document {}
declare var document: Document

@ -1,5 +1,6 @@
import { useData, useRoute } from 'vitepress' import { useData, useRoute } from 'vitepress'
import { defineComponent, h, watch } from 'vue' import { defineComponent, h, watch } from 'vue'
import { contentUpdatedCallbacks } from '../utils' import { contentUpdatedCallbacks } from '../utils'
const runCbs = () => contentUpdatedCallbacks.forEach((fn) => fn()) const runCbs = () => contentUpdatedCallbacks.forEach((fn) => fn())

@ -7,7 +7,8 @@ export function useCodeGroups() {
Array.from(el.children).forEach((child) => { Array.from(el.children).forEach((child) => {
child.classList.remove('active') child.classList.remove('active')
}) })
activate(el.children[0]) const first = el.children[0]
if (first) activate(first)
}) })
}) })
} }

@ -1,11 +1,12 @@
import { inBrowser } from 'vitepress' import { inBrowser } from 'vitepress'
import { isShell } from '../../shared' import { isShell } from '../../shared'
const ignoredNodes = ['.vp-copy-ignore', '.diff.remove'].join(', ') const ignoredNodes = ['.vp-copy-ignore', '.diff.remove'].join(', ')
export function useCopyCode() { export function useCopyCode() {
if (inBrowser) { if (inBrowser) {
const timeoutIdMap: WeakMap<HTMLElement, NodeJS.Timeout> = new WeakMap() const timeoutIdMap: WeakMap<HTMLElement, number> = new WeakMap()
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
const el = e.target as HTMLElement const el = e.target as HTMLElement
if (el.matches('div[class*="language-"] > button.copy')) { if (el.matches('div[class*="language-"] > button.copy')) {
@ -34,7 +35,7 @@ export function useCopyCode() {
copyToClipboard(text).then(() => { copyToClipboard(text).then(() => {
el.classList.add('copied') el.classList.add('copied')
clearTimeout(timeoutIdMap.get(el)) clearTimeout(timeoutIdMap.get(el))
const timeoutId = setTimeout(() => { const timeoutId = window.setTimeout(() => {
el.classList.remove('copied') el.classList.remove('copied')
el.blur() el.blur()
timeoutIdMap.delete(el) timeoutIdMap.delete(el)

@ -1,4 +1,5 @@
import { watchEffect, type Ref } from 'vue' import { watchEffect, type Ref } from 'vue'
import { import {
createTitle, createTitle,
mergeHead, mergeHead,
@ -81,8 +82,8 @@ export function useUpdateHead(route: Route, siteDataByRouteRef: Ref<SiteData>) {
function createHeadElement([tag, attrs, innerHTML]: HeadConfig) { function createHeadElement([tag, attrs, innerHTML]: HeadConfig) {
const el = document.createElement(tag) const el = document.createElement(tag)
for (const key in attrs) { for (const [key, value] of Object.entries(attrs)) {
el.setAttribute(key, attrs[key]) el.setAttribute(key, value)
} }
if (innerHTML) { if (innerHTML) {
el.innerHTML = innerHTML el.innerHTML = innerHTML

@ -2,6 +2,7 @@
// https://github.com/GoogleChromeLabs/quicklink // https://github.com/GoogleChromeLabs/quicklink
import { onMounted, onUnmounted, watch } from 'vue' import { onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from '../router' import { useRoute } from '../router'
import { inBrowser, pathToFile } from '../utils' import { inBrowser, pathToFile } from '../utils'

@ -9,6 +9,7 @@ import {
type InjectionKey, type InjectionKey,
type Ref type Ref
} from 'vue' } from 'vue'
import { import {
APPEARANCE_KEY, APPEARANCE_KEY,
createTitle, createTitle,

@ -1,5 +1,6 @@
import { setupDevToolsPlugin } from '@vue/devtools-api' import { setupDevToolsPlugin } from '@vue/devtools-api'
import type { App } from 'vue' import type { App } from 'vue'
import type { VitePressData } from './data' import type { VitePressData } from './data'
import type { Router } from './router' import type { Router } from './router'

@ -8,6 +8,7 @@ import {
watchEffect, watchEffect,
type App type App
} from 'vue' } from 'vue'
import { ClientOnly } from './components/ClientOnly' import { ClientOnly } from './components/ClientOnly'
import { Content } from './components/Content' import { Content } from './components/Content'
import { useCodeGroups } from './composables/codeGroups' import { useCodeGroups } from './composables/codeGroups'

@ -1,5 +1,6 @@
import type { Component, InjectionKey } from 'vue' import type { Component, InjectionKey } from 'vue'
import { inject, markRaw, nextTick, reactive, readonly } from 'vue' import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
import type { Awaitable, PageData, PageDataPayload, Route } from '../shared' import type { Awaitable, PageData, PageDataPayload, Route } from '../shared'
import { notFoundPageData, treatAsHtml } from '../shared' import { notFoundPageData, treatAsHtml } from '../shared'
import { siteDataRef } from './data' import { siteDataRef } from './data'

@ -1,5 +1,6 @@
// entry for SSR // entry for SSR
import { renderToString } from 'vue/server-renderer' import { renderToString } from 'vue/server-renderer'
import type { SSGContext } from '../shared' import type { SSGContext } from '../shared'
import { createApp } from './index' import { createApp } from './index'

@ -1,4 +1,5 @@
import type { App, Component, Ref } from 'vue' import type { App, Component, Ref } from 'vue'
import type { Awaitable, SiteData } from '../shared' import type { Awaitable, SiteData } from '../shared'
import type { Router } from './router' import type { Router } from './router'

@ -1,5 +1,6 @@
import { tryOnUnmounted } from '@vueuse/core' import { tryOnUnmounted } from '@vueuse/core'
import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue' import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
inBrowser, inBrowser,

@ -1,3 +1,8 @@
// vite/client rather than vitepress/client: the .vue declaration emit runs
// outside a project and cannot resolve self-references, and client.d.ts would
// pull the built dist into the program, clashing with the sources
/// <reference types="vite/client" />
declare const __VP_HASH_MAP__: Record<string, string> declare const __VP_HASH_MAP__: Record<string, string>
declare const __VP_LOCAL_SEARCH__: boolean declare const __VP_LOCAL_SEARCH__: boolean
declare const __ALGOLIA__: boolean declare const __ALGOLIA__: boolean
@ -5,12 +10,6 @@ declare const __CARBON__: boolean
declare const __VUE_PROD_DEVTOOLS__: boolean declare const __VUE_PROD_DEVTOOLS__: boolean
declare const __ASSETS_DIR__: string declare const __ASSETS_DIR__: string
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent
export default component
}
declare module '@siteData' { declare module '@siteData' {
import type { SiteData } from 'vitepress' import type { SiteData } from 'vitepress'
const data: SiteData const data: SiteData

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide, useSlots } from 'vue' import { computed, provide, useSlots } from 'vue'
import VPBackdrop from './components/VPBackdrop.vue' import VPBackdrop from './components/VPBackdrop.vue'
import VPContent from './components/VPContent.vue' import VPContent from './components/VPContent.vue'
import VPFooter from './components/VPFooter.vue' import VPFooter from './components/VPFooter.vue'
@ -9,6 +10,7 @@ import VPSidebar from './components/VPSidebar.vue'
import VPSkipLink from './components/VPSkipLink.vue' import VPSkipLink from './components/VPSkipLink.vue'
import { useData } from './composables/data' import { useData } from './composables/data'
import { layoutInfoInjectionKey, registerWatchers } from './composables/layout' import { layoutInfoInjectionKey, registerWatchers } from './composables/layout'
import { useNav } from './composables/nav'
import { useSidebarControl } from './composables/sidebar' import { useSidebarControl } from './composables/sidebar'
const { const {
@ -17,6 +19,10 @@ const {
close: closeSidebar close: closeSidebar
} = useSidebarControl() } = useSidebarControl()
// everything behind the open nav screen is inert so keyboard and screen
// reader users can't reach the covered page content
const { isScreenOpen } = useNav()
registerWatchers({ closeSidebar }) registerWatchers({ closeSidebar })
const { frontmatter, theme } = useData() const { frontmatter, theme } = useData()
@ -37,7 +43,7 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
]" ]"
> >
<slot name="layout-top" /> <slot name="layout-top" />
<VPSkipLink /> <VPSkipLink :inert="isScreenOpen" />
<VPBackdrop class="backdrop" :show="isSidebarOpen" @click="closeSidebar" /> <VPBackdrop class="backdrop" :show="isSidebarOpen" @click="closeSidebar" />
<VPNav> <VPNav>
<template #nav-bar-title-before><slot name="nav-bar-title-before" /></template> <template #nav-bar-title-before><slot name="nav-bar-title-before" /></template>
@ -47,14 +53,14 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
<template #nav-screen-content-before><slot name="nav-screen-content-before" /></template> <template #nav-screen-content-before><slot name="nav-screen-content-before" /></template>
<template #nav-screen-content-after><slot name="nav-screen-content-after" /></template> <template #nav-screen-content-after><slot name="nav-screen-content-after" /></template>
</VPNav> </VPNav>
<VPLocalNav :open="isSidebarOpen" @open-menu="openSidebar" /> <VPLocalNav :open="isSidebarOpen" @open-menu="openSidebar" :inert="isScreenOpen" />
<VPSidebar :open="isSidebarOpen"> <VPSidebar :open="isSidebarOpen" :inert="isScreenOpen">
<template #sidebar-nav-before><slot name="sidebar-nav-before" /></template> <template #sidebar-nav-before><slot name="sidebar-nav-before" /></template>
<template #sidebar-nav-after><slot name="sidebar-nav-after" /></template> <template #sidebar-nav-after><slot name="sidebar-nav-after" /></template>
</VPSidebar> </VPSidebar>
<VPContent> <VPContent :inert="isScreenOpen">
<template #page-top><slot name="page-top" /></template> <template #page-top><slot name="page-top" /></template>
<template #page-bottom><slot name="page-bottom" /></template> <template #page-bottom><slot name="page-bottom" /></template>
@ -84,7 +90,7 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
<template #aside-ads-after><slot name="aside-ads-after" /></template> <template #aside-ads-after><slot name="aside-ads-after" /></template>
</VPContent> </VPContent>
<VPFooter /> <VPFooter :inert="isScreenOpen" />
<slot name="layout-bottom" /> <slot name="layout-bottom" />
</div> </div>
<Content v-else /> <Content v-else />

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { withBase } from 'vitepress' import { withBase } from 'vitepress'
import { useData } from './composables/data' import { useData } from './composables/data'
import { useLangs } from './composables/langs' import { useLangs } from './composables/langs'
@ -84,9 +85,7 @@ const { currentLang } = useLangs()
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--vp-c-brand-1); color: var(--vp-c-brand-1);
transition: transition: border-color 0.25s, color 0.25s;
border-color 0.25s,
color 0.25s;
} }
.link:hover { .link:hover {

@ -4,6 +4,7 @@ import type { SidepanelInstance } from '@docsearch/sidepanel-js'
import { inBrowser, useRouter } from 'vitepress' import { inBrowser, useRouter } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { nextTick, onUnmounted, watch } from 'vue' import { nextTick, onUnmounted, watch } from 'vue'
import type { DocSearchAskAi } from '../../../../types/docsearch' import type { DocSearchAskAi } from '../../../../types/docsearch'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { import {

@ -30,7 +30,7 @@ defineProps<{
} }
.VPBackdrop.fade-leave-active { .VPBackdrop.fade-leave-active {
transition-duration: .25s; transition-duration: 0.25s;
} }
@media (min-width: 80rem) { @media (min-width: 80rem) {

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { EXTERNAL_URL_RE } from '../../shared' import { EXTERNAL_URL_RE } from '../../shared'
import { normalizeLink } from '../support/utils' import { normalizeLink } from '../support/utils'
@ -9,8 +10,8 @@ interface Props {
theme?: 'brand' | 'alt' | 'sponsor' theme?: 'brand' | 'alt' | 'sponsor'
text?: string text?: string
href?: string href?: string
target?: string; target?: string
rel?: string; rel?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'medium', size: 'medium',

@ -2,7 +2,7 @@
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { onMounted, ref, watch } from 'vue' import { onMounted, useTemplateRef, watch } from 'vue'
const route = useRoute() const route = useRoute()
const props = defineProps<{ const props = defineProps<{
@ -12,7 +12,7 @@ const props = defineProps<{
const carbonOptions = props.carbonAds const carbonOptions = props.carbonAds
const isAsideVisible = useMediaQuery('(min-width: 80rem)') const isAsideVisible = useMediaQuery('(min-width: 80rem)')
const container = ref() const container = useTemplateRef('container')
let isInitialized = false let isInitialized = false
@ -22,13 +22,13 @@ function init() {
const params = new URLSearchParams({ const params = new URLSearchParams({
serve: carbonOptions.code, serve: carbonOptions.code,
placement: carbonOptions.placement, placement: carbonOptions.placement,
format: carbonOptions?.format || 'classic', format: carbonOptions?.format || 'classic'
}) })
const s = document.createElement('script') const s = document.createElement('script')
s.id = '_carbonads_js' s.id = '_carbonads_js'
s.src = `//cdn.carbonads.com/carbon.js?${params.toString()}` s.src = `//cdn.carbonads.com/carbon.js?${params.toString()}`
s.async = true s.async = true
container.value.appendChild(s) container.value?.appendChild(s)
} }
} }

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { resolveDynamicComponent } from 'vue' import { resolveDynamicComponent } from 'vue'
import NotFound from '../NotFound.vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import NotFound from '../NotFound.vue'
import VPDoc from './VPDoc.vue' import VPDoc from './VPDoc.vue'
import VPHome from './VPHome.vue' import VPHome from './VPHome.vue'
import VPPage from './VPPage.vue' import VPPage from './VPPage.vue'

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import { computed } from 'vue' import { computed } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import VPDocAside from './VPDocAside.vue' import VPDocAside from './VPDocAside.vue'

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { useTemplateRef } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import { resolveTitle, useActiveAnchor } from '../composables/outline' import { resolveTitle, useActiveAnchor } from '../composables/outline'
@ -7,8 +8,8 @@ import VPDocOutlineItem from './VPDocOutlineItem.vue'
const { theme } = useData() const { theme } = useData()
const container = ref() const container = useTemplateRef('container')
const marker = ref() const marker = useTemplateRef('marker')
const { headers, hasLocalNav } = useLayout() const { headers, hasLocalNav } = useLayout()
@ -66,10 +67,7 @@ useActiveAnchor(container, marker)
border-radius: 2px; border-radius: 2px;
height: 1.125rem; height: 1.125rem;
background-color: var(--vp-c-brand-1); background-color: var(--vp-c-brand-1);
transition: transition: top 0.25s cubic-bezier(0, 1, 0.5, 1), background-color 0.5s, opacity 0.25s;
top 0.25s cubic-bezier(0, 1, 0.5, 1),
background-color 0.5s,
opacity 0.25s;
} }
.outline-title { .outline-title {

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useEditLink } from '../composables/edit-link' import { useEditLink } from '../composables/edit-link'
import { usePrevNext } from '../composables/prev-next' import { usePrevNext } from '../composables/prev-next'

@ -1,6 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useNavigatorLanguage } from '@vueuse/core' import { useNavigatorLanguage } from '@vueuse/core'
import { computed, onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue' import {
computed,
onMounted,
shallowRef,
useTemplateRef,
watchEffect
} from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
const { theme, page, lang: pageLang } = useData() const { theme, page, lang: pageLang } = useData()

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import VPImage from './VPImage.vue' import VPImage from './VPImage.vue'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue' import { computed } from 'vue'
import VPFeature from './VPFeature.vue' import VPFeature from './VPFeature.vue'
export interface Feature { export interface Feature {

@ -1,6 +1,9 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItem"> <script lang="ts" setup generic="T extends DefaultTheme.NavItem">
import { onKeyStroke, useEventListener } from '@vueuse/core'
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { ref } from 'vue' import { ref, useId, useTemplateRef, watch } from 'vue'
import { useFlyout } from '../composables/flyout' import { useFlyout } from '../composables/flyout'
import VPMenu from './VPMenu.vue' import VPMenu from './VPMenu.vue'
@ -12,40 +15,100 @@ defineProps<{
}>() }>()
const open = ref(false) const open = ref(false)
const el = ref<HTMLElement>() const el = useTemplateRef('el')
const buttonEl = useTemplateRef('buttonEl')
const menuEl = useTemplateRef('menuEl')
const menuId = useId()
useFlyout({ el, onBlur: close })
const route = useRoute()
watch(() => route.path, close)
// for mouse users the disclosure opens and closes on hover, with the same
// boundary in both directions: the button plus the open panel. The root box
// isn't used it also contains the ::before divider some contexts draw
// before the flyout, which should act as a neutral gap either way. Closing
// needs no delay: leaving for the panel (or back) is detected via
// relatedTarget, and any real exit closes instantly so a panel never
// lingers over a neighboring flyout while sweeping across the bar.
// a hover-open absorbs the click that usually follows it, otherwise mouse
// users would toggle the menu right back off
let openedByHover = false
function onPointerEnter(e: PointerEvent) {
if (e.pointerType !== 'mouse') return
if (!open.value) {
open.value = true
openedByHover = true
}
}
useFlyout({ el, onBlur }) function onPointerLeave(e: PointerEvent) {
if (e.pointerType !== 'mouse') return
const to = e.relatedTarget as Node | null
// still within the button panel region not an exit
if (to && (buttonEl.value?.contains(to) || menuEl.value?.contains(to))) return
close()
}
function onBlur() { function toggle() {
if (open.value && openedByHover) {
openedByHover = false
return
}
openedByHover = false
open.value = !open.value
}
function close() {
open.value = false open.value = false
openedByHover = false
}
// content shown on hover must be dismissible without moving the pointer
// (WCAG 1.4.13) Escape closes and, if focus was inside, returns it to the
// trigger
onKeyStroke('Escape', () => {
if (!open.value) return
const restoreFocus = el.value?.contains(document.activeElement)
close()
if (restoreFocus) {
el.value?.querySelector('button')?.focus()
} }
})
// a tap on a non-focusable area outside doesn't move focus, so the
// focus-tracking blur alone can't dismiss the menu on touch
useEventListener('pointerdown', (e) => {
if (open.value && el.value && !el.value.contains(e.target as Node)) close()
})
</script> </script>
<template> <template>
<div <div class="VPFlyout" ref="el">
class="VPFlyout"
ref="el"
@mouseenter="open = true"
@mouseleave="open = false"
>
<button <button
ref="buttonEl"
type="button" type="button"
class="button" class="button"
aria-haspopup="true"
:aria-expanded="open" :aria-expanded="open"
:aria-controls="menuId"
:aria-label="label" :aria-label="label"
@click="open = !open" @pointerenter="onPointerEnter"
@pointerleave="onPointerLeave"
@click="toggle"
> >
<span v-if="button || icon" class="text"> <span v-if="button || icon" class="text">
<span v-if="icon" :class="[icon, 'option-icon']" /> <span v-if="icon" :class="[icon, 'option-icon']" aria-hidden="true" />
<span v-if="button" v-html="button"></span> <span v-if="button" v-html="button"></span>
<span class="vpi-chevron-down text-icon" /> <span class="vpi-chevron-down text-icon" aria-hidden="true" />
</span> </span>
<span v-else class="vpi-more-horizontal icon" /> <span v-else class="vpi-more-horizontal icon" aria-hidden="true" />
</button> </button>
<div class="menu"> <div ref="menuEl" class="menu" :id="menuId" @pointerleave="onPointerLeave">
<VPMenu :items> <VPMenu :items>
<slot /> <slot />
</VPMenu> </VPMenu>
@ -79,17 +142,17 @@ function onBlur() {
color: var(--vp-c-brand-2); color: var(--vp-c-brand-2);
} }
/* closing is snappier than opening so a panel doesn't linger over the
neighboring flyout's panel while sweeping across the bar */
.button[aria-expanded="false"] + .menu { .button[aria-expanded="false"] + .menu {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(0); transition: opacity 0.1s, visibility 0.1s;
} }
.VPFlyout:hover .menu,
.button[aria-expanded="true"] + .menu { .button[aria-expanded="true"] + .menu {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: translateY(0);
} }
.button { .button {
@ -112,7 +175,6 @@ function onBlur() {
} }
.option-icon { .option-icon {
margin-right: 0px;
font-size: 1rem; font-size: 1rem;
} }
@ -132,6 +194,6 @@ function onBlur() {
right: 0; right: 0;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.25s, visibility 0.25s, transform 0.25s; transition: opacity 0.25s, visibility 0.25s;
} }
</style> </style>

@ -7,10 +7,22 @@ const { hasSidebar } = useLayout()
</script> </script>
<template> <template>
<footer v-if="theme.footer && frontmatter.footer !== false" class="VPFooter" :class="{ 'has-sidebar': hasSidebar }"> <footer
v-if="theme.footer && frontmatter.footer !== false"
class="VPFooter"
:class="{ 'has-sidebar': hasSidebar }"
>
<div class="container"> <div class="container">
<p v-if="theme.footer.message" class="message" v-html="theme.footer.message"></p> <p
<p v-if="theme.footer.copyright" class="copyright" v-html="theme.footer.copyright"></p> v-if="theme.footer.message"
class="message"
v-html="theme.footer.message"
></p>
<p
v-if="theme.footer.copyright"
class="copyright"
v-html="theme.footer.copyright"
></p>
</div> </div>
</footer> </footer>
</template> </template>

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { layoutInfoInjectionKey } from '../composables/layout' import { layoutInfoInjectionKey } from '../composables/layout'
import VPButton from './VPButton.vue' import VPButton from './VPButton.vue'
import VPImage from './VPImage.vue' import VPImage from './VPImage.vue'

@ -10,9 +10,8 @@ const { frontmatter, theme } = useData()
<template> <template>
<div <div
class="VPHome" class="VPHome"
:class="{ :class="{ 'external-link-icon-enabled': theme.externalLinkIcon }"
'external-link-icon-enabled': theme.externalLinkIcon >
}">
<slot name="home-hero-before" /> <slot name="home-hero-before" />
<VPHomeHero> <VPHomeHero>
<template #home-hero-info-before><slot name="home-hero-info-before" /></template> <template #home-hero-info-before><slot name="home-hero-info-before" /></template>

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme'
import { withBase } from 'vitepress' import { withBase } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
defineProps<{ defineProps<{
image: DefaultTheme.ThemeableImage image: DefaultTheme.ThemeableImage

@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { isLinkExternal, normalizeLink } from '../support/utils' import { isLinkExternal, normalizeLink } from '../support/utils'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -10,7 +11,7 @@ const props = withDefaults(defineProps<{
target?: string target?: string
rel?: string rel?: string
}>(), { }>(), {
external: undefined, external: undefined
}) })
const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span')) const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span'))

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useWindowScroll } from '@vueuse/core' import { useWindowScroll } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import VPLocalNavOutlineDropdown from './VPLocalNavOutlineDropdown.vue' import VPLocalNavOutlineDropdown from './VPLocalNavOutlineDropdown.vue'
@ -30,30 +31,29 @@ onMounted(() => {
probe.remove() probe.remove()
}) })
const classes = computed(() => { const isScrolled = computed(() => y.value >= navHeight.value)
return {
VPLocalNav: true,
'has-sidebar': hasSidebar.value,
empty: !hasLocalNav.value,
fixed: !hasLocalNav.value && !hasSidebar.value
}
})
</script> </script>
<template> <template>
<div <div
v-if="!isHome && (hasLocalNav || hasSidebar || y >= navHeight)" v-if="!isHome && (hasLocalNav || hasSidebar || isScrolled)"
:class="classes" class="VPLocalNav"
:class="{
'has-sidebar': hasSidebar,
'empty': !hasLocalNav,
'fixed': !hasLocalNav && !hasSidebar
}"
> >
<div class="container"> <div class="container">
<button <button
v-if="hasSidebar" v-if="hasSidebar"
type="button"
class="menu" class="menu"
:aria-expanded="open" :aria-expanded="open"
aria-controls="VPSidebarNav" aria-controls="VPSidebarNav"
@click="$emit('open-menu')" @click="$emit('open-menu')"
> >
<span class="vpi-align-left menu-icon"></span> <span class="vpi-align-left menu-icon" aria-hidden="true"></span>
<span class="menu-text"> <span class="menu-text">
{{ theme.sidebarMenuLabel || 'Menu' }} {{ theme.sidebarMenuLabel || 'Menu' }}
</span> </span>
@ -71,10 +71,23 @@ const classes = computed(() => {
/*rtl:ignore*/ /*rtl:ignore*/
left: 0; left: 0;
z-index: var(--vp-z-index-local-nav); z-index: var(--vp-z-index-local-nav);
border-bottom: 1px solid var(--vp-c-gutter); border-bottom: 1px solid var(--vp-local-nav-divider-color);
padding-top: var(--vp-layout-top-height, 0px); padding-top: var(--vp-layout-top-height, 0px);
width: 100%; width: 100%;
}
/* the background surface below 60rem it covers just this bar; from 60rem
the bar is pinned under the fixed navbar, so the surface extends up
behind it and one element carries the backdrop filter for both bars
(two stacked filters would show a seam at their shared edge) */
.VPLocalNav::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background-color: var(--vp-local-nav-bg-color); background-color: var(--vp-local-nav-bg-color);
backdrop-filter: var(--vp-nav-backdrop-filter);
transition: background-color 0.25s;
} }
.VPLocalNav.fixed { .VPLocalNav.fixed {
@ -86,6 +99,10 @@ const classes = computed(() => {
top: var(--vp-nav-height); top: var(--vp-nav-height);
} }
.VPLocalNav::before {
top: calc(-1 * var(--vp-nav-height));
}
.VPLocalNav.has-sidebar { .VPLocalNav.has-sidebar {
padding-left: var(--vp-sidebar-width); padding-left: var(--vp-sidebar-width);
} }

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onKeyStroke, useScrollLock } from '@vueuse/core' import { onKeyStroke } from '@vueuse/core'
import { inBrowser, onContentUpdated } from 'vitepress' import { onContentUpdated } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { nextTick, ref, watch } from 'vue' import { nextTick, ref, useId, useTemplateRef, watch } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { resolveTitle } from '../composables/outline' import { resolveTitle } from '../composables/outline'
import { useBodyScrollLock } from '../composables/scroll-lock'
import VPDocOutlineItem from './VPDocOutlineItem.vue' import VPDocOutlineItem from './VPDocOutlineItem.vue'
const props = defineProps<{ const props = defineProps<{
@ -15,11 +17,12 @@ const props = defineProps<{
const { theme } = useData() const { theme } = useData()
const open = ref(false) const open = ref(false)
const vh = ref(0) const vh = ref(0)
const main = ref<HTMLDivElement>() const main = useTemplateRef('main')
const items = ref<HTMLDivElement>() const items = useTemplateRef('items')
const itemsId = useId()
// lock body scroll while the dropdown is open to prevent scroll chaining // lock body scroll while the dropdown is open to prevent scroll chaining
const isLocked = useScrollLock(inBrowser ? document.body : null) const isLocked = useBodyScrollLock()
function closeOnClickOutside(e: Event) { function closeOnClickOutside(e: Event) {
if (!main.value?.contains(e.target as Node)) { if (!main.value?.contains(e.target as Node)) {
@ -74,15 +77,22 @@ function scrollToTop() {
:style="{ '--vp-vh': vh + 'px' }" :style="{ '--vp-vh': vh + 'px' }"
data-allow-mismatch="style" data-allow-mismatch="style"
> >
<button @click="toggle" :class="{ open }" v-if="headers.length > 0"> <button
v-if="headers.length > 0"
type="button"
:aria-expanded="open"
:aria-controls="itemsId"
:class="{ open }"
@click="toggle"
>
<span class="menu-text">{{ resolveTitle(theme) }}</span> <span class="menu-text">{{ resolveTitle(theme) }}</span>
<span class="vpi-chevron-right icon" /> <span class="vpi-chevron-right icon" aria-hidden="true" />
</button> </button>
<button @click="scrollToTop" v-else> <button v-else type="button" @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }} {{ theme.returnToTopLabel || 'Return to top' }}
</button> </button>
<Transition name="flyout"> <Transition name="flyout">
<div v-if="open" ref="items" class="items" @click="onItemClick"> <div v-if="open" ref="items" :id="itemsId" class="items" @click="onItemClick">
<div class="header"> <div class="header">
<a class="top-link" href="#" @click="scrollToTop"> <a class="top-link" href="#" @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }} {{ theme.returnToTopLabel || 'Return to top' }}

@ -6,13 +6,12 @@ import {
onKeyStroke, onKeyStroke,
useEventListener, useEventListener,
useLocalStorage, useLocalStorage,
useScrollLock,
useSessionStorage useSessionStorage
} from '@vueuse/core' } from '@vueuse/core'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Mark from 'mark.js/src/vanilla.js' import Mark from 'mark.js/src/vanilla.js'
import MiniSearch, { type SearchResult } from 'minisearch' import MiniSearch, { type SearchResult } from 'minisearch'
import { dataSymbol, inBrowser, useRouter } from 'vitepress' import { dataSymbol, useRouter } from 'vitepress'
import { import {
computed, computed,
createApp, createApp,
@ -22,14 +21,17 @@ import {
onMounted, onMounted,
ref, ref,
shallowRef, shallowRef,
useTemplateRef,
watch, watch,
watchEffect, watchEffect,
type Ref type Ref
} from 'vue' } from 'vue'
import type { LocalSearchTranslations } from '../../../../types/local-search' import type { LocalSearchTranslations } from '../../../../types/local-search'
import { pathToFile } from '../../app/utils' import { pathToFile } from '../../app/utils'
import { escapeRegExp } from '../../shared' import { escapeRegExp } from '../../shared'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useBodyScrollLock } from '../composables/scroll-lock'
import { LRUCache } from '../support/lru' import { LRUCache } from '../support/lru'
import { createSearchTranslate } from '../support/translation' import { createSearchTranslate } from '../support/translation'
@ -37,8 +39,8 @@ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
}>() }>()
const el = shallowRef<HTMLElement>() const el = useTemplateRef('el')
const resultsEl = shallowRef<HTMLElement>() const resultsEl = useTemplateRef('resultsEl')
/* Search */ /* Search */
@ -74,11 +76,11 @@ const showSearchSpinner = computed(() => {
}) })
const searchIndex = computedAsync( const searchIndex = computedAsync(
async () => async () => {
markRaw( const json = (await searchIndexData.value[localeIndex.value]?.())?.default
MiniSearch.loadJSON<Result>( if (!json) return null
(await searchIndexData.value[localeIndex.value]?.())?.default, return markRaw(
{ MiniSearch.loadJSON<Result>(json, {
fields: ['title', 'titles', 'text'], fields: ['title', 'titles', 'text'],
storeFields: ['title', 'titles'], storeFields: ['title', 'titles'],
searchOptions: { searchOptions: {
@ -90,9 +92,9 @@ const searchIndex = computedAsync(
}, },
...(theme.value.search?.provider === 'local' && ...(theme.value.search?.provider === 'local' &&
theme.value.search.options?.miniSearch?.options) theme.value.search.options?.miniSearch?.options)
} })
) )
), },
undefined, undefined,
isSearchIndexLoading isSearchIndexLoading
) )
@ -264,7 +266,7 @@ async function fetchExcerpt(id: string) {
/* Search input focus */ /* Search input focus */
const searchInput = ref<HTMLInputElement>() const searchInput = useTemplateRef('searchInput')
const disableReset = computed(() => { const disableReset = computed(() => {
return filterText.value?.length <= 0 return filterText.value?.length <= 0
}) })
@ -409,7 +411,7 @@ useEventListener('popstate', (event) => {
/** Lock body */ /** Lock body */
const isLocked = useScrollLock(inBrowser ? document.body : null) const isLocked = useBodyScrollLock()
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
@ -486,7 +488,7 @@ function onMouseMove(e: MouseEvent) {
<input <input
ref="searchInput" ref="searchInput"
v-model="filterText" v-model="filterText"
:aria-activedescendant="selectedIndex > -1 ? ('localsearch-item-' + selectedIndex) : undefined" :aria-activedescendant="selectedIndex > -1 ? 'localsearch-item-' + selectedIndex : undefined"
aria-autocomplete="both" aria-autocomplete="both"
:aria-controls="results?.length ? 'localsearch-list' : undefined" :aria-controls="results?.length ? 'localsearch-list' : undefined"
aria-labelledby="localsearch-label" aria-labelledby="localsearch-label"
@ -593,8 +595,7 @@ function onMouseMove(e: MouseEvent) {
v-if="filterText && !results.length && enableNoResults" v-if="filterText && !results.length && enableNoResults"
class="no-results" class="no-results"
> >
{{ translate('modal.noResultsText') }} "<strong>{{ filterText }}</strong {{ translate('modal.noResultsText') }} "<strong>{{ filterText }}</strong>"
>"
</li> </li>
</ul> </ul>

@ -1,7 +1,8 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItem"> <script lang="ts" setup generic="T extends DefaultTheme.NavItem">
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import VPMenuLink from './VPMenuLink.vue'
import VPMenuGroup from './VPMenuGroup.vue' import VPMenuGroup from './VPMenuGroup.vue'
import VPMenuLink from './VPMenuLink.vue'
defineProps<{ defineProps<{
items?: T[] items?: T[]
@ -17,6 +18,7 @@ defineProps<{
v-else-if="'component' in item" v-else-if="'component' in item"
:is="item.component" :is="item.component"
v-bind="item.props" v-bind="item.props"
menu
/> />
<VPMenuGroup v-else :text="item.text" :items="item.items" /> <VPMenuGroup v-else :text="item.text" :items="item.items" />
</template> </template>
@ -63,16 +65,4 @@ defineProps<{
white-space: nowrap; white-space: nowrap;
} }
.VPMenu :deep(.label) {
flex-grow: 1;
line-height: 2.3333333;
font-size: 0.75rem;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color 0.5s;
}
.VPMenu :deep(.action) {
padding-left: 1.5rem;
}
</style> </style>

@ -1,20 +1,49 @@
<script lang="ts" setup generic="T extends (DefaultTheme.NavItemComponent | DefaultTheme.NavItemChildren | DefaultTheme.NavItemWithLink)"> <script
lang="ts"
setup
generic="
T extends
| DefaultTheme.NavItemComponent
| DefaultTheme.NavItemChildren
| DefaultTheme.NavItemWithLink
"
>
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue'
import { navScreenInjectionKey } from '../composables/nav'
import VPMenuLink from './VPMenuLink.vue' import VPMenuLink from './VPMenuLink.vue'
defineProps<{ const props = defineProps<{
text?: string text?: string
items: T[] items: T[]
}>() }>()
const screen = inject(navScreenInjectionKey, false)
const hasSubGroups = computed(() =>
props.items.some((item) => !('link' in item) && !('component' in item))
)
</script> </script>
<template> <template>
<li class="VPMenuGroup"> <li
class="VPMenuGroup"
:class="{ VPNavScreenMenuGroupSection: screen }"
>
<p v-if="text" class="title">{{ text }}</p> <p v-if="text" class="title">{{ text }}</p>
<ul> <ul :class="{ 'sub-groups': hasSubGroups }">
<template v-for="item in items" :key="JSON.stringify(item)"> <template v-for="item in items" :key="JSON.stringify(item)">
<VPMenuLink v-if="'link' in item" :item /> <VPMenuLink v-if="'link' in item" :item />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
:screen-menu="screen || undefined"
:menu="!screen || undefined"
/>
<VPMenuGroup v-else :text="item.text" :items="item.items" />
</template> </template>
</ul> </ul>
</li> </li>
@ -47,4 +76,35 @@ defineProps<{
white-space: nowrap; white-space: nowrap;
transition: color 0.25s; transition: color 0.25s;
} }
.VPNavScreen .VPMenuGroup {
margin: 0;
border: none;
padding: 0;
}
.VPNavScreen .title {
padding: 0;
line-height: 2.4615385;
font-size: 0.8125rem;
font-weight: 700;
white-space: normal;
}
.VPMenuGroup > .sub-groups {
margin: 0.25rem 0 0.25rem 0.75rem;
border-left: 1px solid var(--vp-c-divider);
padding-left: 0.25rem;
}
.VPMenuGroup .VPMenuGroup,
.VPMenuGroup .VPMenuGroup + .VPMenuGroup {
margin: 0;
border-top: 0;
padding: 0.5rem 0 0;
}
.VPMenuGroup .VPMenuGroup:first-child {
padding-top: 0;
}
</style> </style>

@ -1,8 +1,12 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink"> <script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink">
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue' import { inject } from 'vue'
import { isActive } from '../../shared'
import {
navInjectionKey,
navScreenInjectionKey,
useNavItemLink
} from '../composables/nav'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
const props = defineProps<{ const props = defineProps<{
@ -10,22 +14,14 @@ const props = defineProps<{
rel?: string rel?: string
}>() }>()
const route = useRoute() const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
const href = computed(() => const screen = inject(navScreenInjectionKey, false)
typeof props.item.link === 'function' const nav = inject(navInjectionKey, null)
? props.item.link(route.data)
: props.item.link
)
const isActiveLink = computed(() => { function onClick() {
return isActive( if (screen) nav?.closeScreen()
route.data.relativePath, }
route.hash,
props.item.activeMatch || href.value,
!!props.item.activeMatch
)
})
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
</script> </script>
@ -34,11 +30,16 @@ defineOptions({ inheritAttrs: false })
<li class="VPMenuLink"> <li class="VPMenuLink">
<VPLink <VPLink
v-bind="$attrs" v-bind="$attrs"
:class="{ active: isActiveLink }" :class="{
active: isActiveLink,
VPNavScreenMenuGroupLink: screen
}"
:aria-current="isCurrentLink ? 'page' : undefined"
:href :href
:target="item.target" :target="item.target"
:rel="props.rel ?? item.rel" :rel="props.rel ?? item.rel"
:no-icon="item.noIcon" :no-icon="item.noIcon"
@click="onClick"
> >
<span v-html="item.text"></span> <span v-html="item.text"></span>
</VPLink> </VPLink>
@ -52,6 +53,12 @@ defineOptions({ inheritAttrs: false })
padding: 0.75rem 0.75rem 0; padding: 0.75rem 0.75rem 0;
} }
.VPMenuGroup .VPMenuGroup + .VPMenuLink {
margin: 0;
border-top: 0;
padding: 0.5rem 0 0;
}
.link { .link {
display: block; display: block;
border-radius: 0.375rem; border-radius: 0.375rem;
@ -62,9 +69,7 @@ defineOptions({ inheritAttrs: false })
color: var(--vp-c-text-1); color: var(--vp-c-text-1);
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
transition: transition: background-color 0.25s, color 0.25s;
background-color 0.25s,
color 0.25s;
} }
.link:hover { .link:hover {
@ -75,4 +80,23 @@ defineOptions({ inheritAttrs: false })
.link.active { .link.active {
color: var(--vp-c-brand-1); color: var(--vp-c-brand-1);
} }
.VPNavScreen .VPMenuLink {
margin: 0;
border: none;
padding: 0;
}
.VPNavScreen .link {
display: block;
margin-left: 0.75rem;
border-radius: 0;
padding: 0;
font-weight: 400;
white-space: normal;
}
.VPNavScreen .link:hover {
background-color: transparent;
}
</style> </style>

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { inBrowser } from 'vitepress' import { inBrowser } from 'vitepress'
import { computed, provide, watchEffect } from 'vue' import { computed, provide, watchEffect } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { navInjectionKey, useNav } from '../composables/nav' import { navInjectionKey, useNav } from '../composables/nav'
import VPNavBar from './VPNavBar.vue' import VPNavBar from './VPNavBar.vue'
@ -46,7 +47,6 @@ watchEffect(() => {
z-index: var(--vp-z-index-nav); z-index: var(--vp-z-index-nav);
width: 100%; width: 100%;
pointer-events: none; pointer-events: none;
transition: background-color 0.5s;
} }
@media (min-width: 60rem) { @media (min-width: 60rem) {

@ -0,0 +1,92 @@
<script lang="ts" setup>
import { computed, useId } from 'vue'
import { useData } from '../composables/data'
import { useAppearanceSwitch } from '../composables/nav'
import { useNavOverflow } from '../composables/nav-overflow'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const props = defineProps<{
/** labeled row (nav screen and `⋯` menu) instead of the bare switch */
row?: boolean
/** styling context for the row variant */
screen?: boolean
}>()
const { theme } = useData()
const show = useAppearanceSwitch()
// only the inline bar switch participates in the overflow engine
const overflow = props.row ? null : useNavOverflow()
const isCollapsed = computed(() => !!overflow && !overflow.state.appearance)
const labelId = useId()
</script>
<template>
<div
v-if="show"
class="VPNavAppearance"
:class="[
row ? (screen ? 'VPNavScreenAppearance' : 'menu-appearance') : 'VPNavBarAppearance',
{ collapsed: isCollapsed }
]"
:ref="(el) => overflow?.setClusterEl('appearance', el as HTMLElement | null)"
>
<p v-if="row" :id="labelId" class="text">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<VPSwitchAppearance :aria-labelledby="row ? labelId : undefined" />
</div>
</template>
<style scoped>
.VPNavBarAppearance {
display: none;
}
@media (min-width: 48rem) {
.VPNavBarAppearance {
display: flex;
align-items: center;
}
}
.VPNavAppearance .text {
font-size: 0.75rem;
font-weight: 500;
color: var(--vp-c-text-2);
}
/* labeled row inside the nav screen */
.VPNavScreenAppearance {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 0.5rem;
padding: 0.75rem 0.875rem 0.75rem 1rem;
background-color: var(--vp-c-bg-soft);
}
.VPNavScreenAppearance .text {
line-height: 2;
}
/* labeled row inside the `⋯` menu */
.menu-appearance {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
min-width: 11rem;
padding: 0 0.75rem;
}
/* matches the menu group titles */
.menu-appearance .text {
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 600;
}
</style>

@ -1,16 +1,20 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useWindowScroll } from '@vueuse/core' import { useWindowScroll } from '@vueuse/core'
import { computed } from 'vue'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import VPNavBarAppearance from './VPNavBarAppearance.vue' import { provideNavOverflow } from '../composables/nav-overflow'
import VPNavAppearance from './VPNavAppearance.vue'
import VPNavBarExtra from './VPNavBarExtra.vue' import VPNavBarExtra from './VPNavBarExtra.vue'
import VPNavBarHamburger from './VPNavBarHamburger.vue' import VPNavBarHamburger from './VPNavBarHamburger.vue'
import VPNavBarMenu from './VPNavBarMenu.vue'
import VPNavBarSearch from './VPNavBarSearch.vue' import VPNavBarSearch from './VPNavBarSearch.vue'
import VPNavBarSocialLinks from './VPNavBarSocialLinks.vue'
import VPNavBarTitle from './VPNavBarTitle.vue' import VPNavBarTitle from './VPNavBarTitle.vue'
import VPNavBarTranslations from './VPNavBarTranslations.vue' import VPNavMenu from './VPNavMenu.vue'
import VPNavSocialLinks from './VPNavSocialLinks.vue'
import VPNavTranslations from './VPNavTranslations.vue'
const props = defineProps<{ defineProps<{
isScreenOpen: boolean isScreenOpen: boolean
}>() }>()
@ -18,8 +22,15 @@ defineEmits<{
(e: 'toggle-screen'): void (e: 'toggle-screen'): void
}>() }>()
const { theme } = useData()
const { isHome, hasSidebar, hasLocalNav } = useLayout()
const { y } = useWindowScroll() const { y } = useWindowScroll()
const { isHome, hasSidebar } = useLayout() const isTop = computed(() => y.value <= 0)
const overflow = provideNavOverflow({
itemsKey: () => JSON.stringify(theme.value.nav ?? null)
})
</script> </script>
<template> <template>
@ -27,8 +38,9 @@ const { isHome, hasSidebar } = useLayout()
class="VPNavBar" class="VPNavBar"
:class="{ :class="{
'has-sidebar': hasSidebar, 'has-sidebar': hasSidebar,
'has-local-nav': !isHome && hasLocalNav,
'home': isHome, 'home': isHome,
'top': y <= 0, 'top': isTop,
'screen-open': isScreenOpen 'screen-open': isScreenOpen
}" }"
> >
@ -42,13 +54,16 @@ const { isHome, hasSidebar } = useLayout()
</div> </div>
<div class="content"> <div class="content">
<div class="content-body"> <div
class="content-body"
:ref="(el) => overflow.setContainerEl(el as HTMLElement | null)"
>
<slot name="nav-bar-content-before" /> <slot name="nav-bar-content-before" />
<VPNavBarSearch class="search" /> <VPNavBarSearch class="search" />
<VPNavBarMenu class="menu" /> <VPNavMenu class="menu" />
<VPNavBarTranslations class="translations" /> <VPNavTranslations class="translations" />
<VPNavBarAppearance class="appearance" /> <VPNavAppearance class="appearance" />
<VPNavBarSocialLinks class="social-links" /> <VPNavSocialLinks class="social-links" />
<VPNavBarExtra class="extra" /> <VPNavBarExtra class="extra" />
<slot name="nav-bar-content-after" /> <slot name="nav-bar-content-after" />
<VPNavBarHamburger <VPNavBarHamburger
@ -74,26 +89,66 @@ const { isHome, hasSidebar } = useLayout()
height: var(--vp-nav-height); height: var(--vp-nav-height);
pointer-events: none; pointer-events: none;
white-space: nowrap; white-space: nowrap;
transition: background-color 0.25s; /* left edge of the background surface and divider on doc pages the
sidebar column paints its own surface up to this offset */
--vp-nav-col-offset: 0px;
} }
.VPNavBar.screen-open { /* the single background surface every state change below is color-only,
transition: none; so nothing ever moves */
.VPNavBar::before {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: var(--vp-nav-col-offset);
z-index: -1;
background-color: var(--vp-nav-bg-color); background-color: var(--vp-nav-bg-color);
backdrop-filter: var(--vp-nav-backdrop-filter);
transition: background-color 0.25s;
} }
.VPNavBar:not(.home) { /* below 60rem the bar scrolls with the page, so home stays transparent */
background-color: var(--vp-nav-bg-color); .VPNavBar.home::before {
background-color: transparent;
} }
@media (min-width: 60rem) { @media (min-width: 60rem) {
.VPNavBar:not(.home) { .VPNavBar.home::before {
background-color: transparent; background-color: var(--vp-nav-bg-color);
}
.VPNavBar.home.top::before {
background-color: var(--vp-nav-home-bg-color);
backdrop-filter: none;
}
.VPNavBar.has-sidebar {
--vp-nav-col-offset: var(--vp-sidebar-width);
}
}
@media (min-width: 90rem) {
.VPNavBar.has-sidebar {
--vp-nav-col-offset: calc(
(100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width)
);
}
} }
.VPNavBar:not(.has-sidebar):not(.home.top) { .VPNavBar.screen-open::before {
transition: none;
background-color: var(--vp-nav-bg-color); background-color: var(--vp-nav-bg-color);
} }
/* between 60rem and 80rem the local nav is pinned right under the bar and
its surface extends up behind it, carrying the paint for both bars */
@media (60rem <= width < 80rem) {
.VPNavBar.has-local-nav::before {
background-color: transparent;
backdrop-filter: none;
}
} }
.wrapper { .wrapper {
@ -106,12 +161,6 @@ const { isHome, hasSidebar } = useLayout()
} }
} }
@media (min-width: 60rem) {
.VPNavBar.has-sidebar .wrapper {
padding: 0;
}
}
.container { .container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -130,87 +179,67 @@ const { isHome, hasSidebar } = useLayout()
pointer-events: auto; pointer-events: auto;
} }
@media (min-width: 60rem) { .title {
.VPNavBar.has-sidebar .container { /* below the overflow engine's range the title is the only shrinkable
max-width: 100%; piece, truncating instead of running over search and the hamburger */
} min-width: 0;
} }
@media (min-width: 48rem) {
.title { .title {
/* the overflow engine measures fixed occupancy around a rigid title */
flex-shrink: 0; flex-shrink: 0;
height: calc(var(--vp-nav-height) - 1px); }
transition: background-color 0.5s;
} }
@media (min-width: 60rem) { @media (min-width: 60rem) {
.VPNavBar.has-sidebar .title { /* outside home the title column matches the sidebar column, so search and
position: absolute; menu sit at the same spot on every doc page; on home the title keeps its
top: 0; natural width and search sits right next to it */
left: 0; .VPNavBar:not(.home) .title {
z-index: 2; min-width: calc(var(--vp-sidebar-width) - 2rem);
padding: 0 2rem;
width: var(--vp-sidebar-width);
height: var(--vp-nav-height);
background-color: transparent;
}
} }
@media (min-width: 90rem) {
.VPNavBar.has-sidebar .title { .VPNavBar.has-sidebar .title {
padding-left: max(2rem, calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2)); max-width: calc(var(--vp-sidebar-width) - 2rem);
width: calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2 + var(--vp-sidebar-width) - 2rem);
} }
} }
.content { .content {
flex-grow: 1; flex-grow: 1;
/* below the engine's range the controls stay rigid and the title absorbs
all the shrink */
flex-shrink: 0;
} }
@media (min-width: 60rem) { @media (min-width: 48rem) {
.VPNavBar.has-sidebar .content { .content {
position: relative; flex-shrink: 1;
z-index: 1; min-width: 0;
padding-left: var(--vp-sidebar-width);
padding-right: 2rem;
}
}
@media (min-width: 90rem) {
.VPNavBar.has-sidebar .content {
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
padding-right: calc((100% - var(--vp-layout-max-width)) / 2 + 2rem);
} }
} }
.content-body { .content-body {
position: relative;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
height: var(--vp-nav-height); height: var(--vp-nav-height);
transition: background-color 0.5s;
}
@media (min-width: 60rem) {
.VPNavBar:not(.home.top) .content-body {
position: relative;
background-color: var(--vp-nav-bg-color);
}
.VPNavBar:not(.has-sidebar):not(.home.top) .content-body {
background-color: transparent;
} }
.content-body { /* collapsed into the `` menu kept mounted (hidden, out of the a11y tree
margin-right: -100vw; and tab order) so its natural width stays measurable */
padding-right: 100vw; .content-body > .collapsed {
} visibility: hidden;
position: absolute;
top: 0;
left: 0;
max-width: 100%;
overflow: hidden;
} }
.menu + .translations::before, /* separators between whichever cluster units are currently in the bar */
.menu + .appearance::before, .content-body > :where(.menu, .translations, .appearance, .social-links) + :where(.translations, .appearance, .social-links)::before {
.menu + .social-links::before,
.translations + .appearance::before,
.appearance + .social-links::before {
margin-right: 0.5rem; margin-right: 0.5rem;
margin-left: 0.5rem; margin-left: 0.5rem;
width: 1px; width: 1px;
@ -219,12 +248,11 @@ const { isHome, hasSidebar } = useLayout()
content: ""; content: "";
} }
.menu + .appearance::before, .content-body > :where(.menu, .translations) + .appearance::before {
.translations + .appearance::before {
margin-right: 1rem; margin-right: 1rem;
} }
.appearance + .social-links::before { .content-body > .appearance + .social-links::before {
margin-left: 1rem; margin-left: 1rem;
} }
@ -233,20 +261,25 @@ const { isHome, hasSidebar } = useLayout()
} }
.divider { .divider {
position: relative;
/* above the background surface, below the bar's content an open flyout
panel overlaps the bar's bottom edge and must cover the rule */
z-index: -1;
width: 100%; width: 100%;
height: 1px; height: 1px;
padding-left: var(--vp-nav-col-offset);
} }
@media (min-width: 60rem) { /* the sidebar-column segment of the bottom rule inset from the column
.VPNavBar.has-sidebar .divider { edges so it lines up with the sidebar's own group dividers */
padding-left: var(--vp-sidebar-width); .VPNavBar.has-sidebar .divider::before {
} content: "";
} position: absolute;
top: 0;
@media (min-width: 90rem) { left: calc(var(--vp-nav-col-offset) - var(--vp-sidebar-width) + 2rem);
.VPNavBar.has-sidebar .divider { width: calc(var(--vp-sidebar-width) - 4rem);
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width)); height: 1px;
} background-color: var(--vp-c-divider);
} }
.divider-line { .divider-line {
@ -256,20 +289,16 @@ const { isHome, hasSidebar } = useLayout()
} }
.VPNavBar:not(.home) .divider-line { .VPNavBar:not(.home) .divider-line {
background-color: var(--vp-c-gutter); background-color: var(--vp-nav-divider-color);
}
.VPNavBar.screen-open .divider-line {
background-color: var(--vp-c-divider);
} }
@media (min-width: 60rem) { @media (min-width: 60rem) {
.divider-line {
transition: background-color 0.5s;
}
.VPNavBar:not(.home.top) .divider-line { .VPNavBar:not(.home.top) .divider-line {
background-color: var(--vp-c-gutter); background-color: var(--vp-nav-divider-color);
}
} }
.VPNavBar.screen-open .divider-line {
background-color: var(--vp-c-divider);
} }
</style> </style>

@ -1,32 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site } = useData()
</script>
<template>
<div
v-if="
site.appearance &&
site.appearance !== 'force-dark' &&
site.appearance !== 'force-auto'
"
class="VPNavBarAppearance"
>
<VPSwitchAppearance />
</div>
</template>
<style scoped>
.VPNavBarAppearance {
display: none;
}
@media (min-width: 80rem) {
.VPNavBarAppearance {
display: flex;
align-items: center;
}
}
</style>

@ -1,71 +1,86 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useLangs } from '../composables/langs' import { useLangs } from '../composables/langs'
import { useAppearanceSwitch } from '../composables/nav'
import { useNavOverflow } from '../composables/nav-overflow'
import VPFlyout from './VPFlyout.vue' import VPFlyout from './VPFlyout.vue'
import VPMenuGroup from './VPMenuGroup.vue'
import VPMenuLink from './VPMenuLink.vue' import VPMenuLink from './VPMenuLink.vue'
import VPNavAppearance from './VPNavAppearance.vue'
import VPNavTranslations from './VPNavTranslations.vue'
import VPSocialLinks from './VPSocialLinks.vue' import VPSocialLinks from './VPSocialLinks.vue'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site, theme } = useData() const { theme } = useData()
const { localeLinks, currentLang } = useLangs({ const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true linkToCorrespondingPage: true
}) })
const hasAppearanceSwitch = useAppearanceSwitch()
const overflow = useNavOverflow()
// nav items the priority+ engine pushed out of the bar (contiguous suffix)
const overflowItems = computed(() => {
const count = overflow?.state.visibleItemCount ?? Infinity
if (count === Infinity || !theme.value.nav) return []
return theme.value.nav.slice(count)
})
const showTranslations = computed(
() =>
!!(localeLinks.value.length && currentLang.value.label) &&
!(overflow?.state.translations ?? true)
)
const hasExtraContent = computed( const showAppearance = computed(
() => hasAppearanceSwitch.value && !(overflow?.state.appearance ?? true)
)
const showSocialLinks = computed(
() => !!theme.value.socialLinks && !(overflow?.state.socialLinks ?? true)
)
const hasContent = computed(
() => () =>
(localeLinks.value.length && currentLang.value.label) || overflowItems.value.length > 0 ||
site.value.appearance || showTranslations.value ||
theme.value.socialLinks showAppearance.value ||
showSocialLinks.value
) )
</script> </script>
<template> <template>
<VPFlyout <VPFlyout
v-if="hasExtraContent" v-if="hasContent"
class="VPNavBarExtra" class="VPNavBarExtra"
label="extra navigation" :label="theme.extraMenuLabel || 'More options'"
:ref="(inst: any) => overflow?.setExtraEl(inst?.$el ?? null)"
> >
<ul <ul v-if="overflowItems.length" class="group overflow-items">
v-if="localeLinks.length && currentLang.label" <template v-for="item in overflowItems" :key="JSON.stringify(item)">
class="group translations" <VPMenuLink v-if="'link' in item" :item />
> <!-- a menu panel is a vertical list context components must
<li class="trans-title">{{ currentLang.label }}</li> render a flat list here, not a nested floating flyout -->
<component
<template v-for="locale in localeLinks" :key="locale.link"> v-else-if="'component' in item"
<VPMenuLink :is="item.component"
:item="locale" v-bind="item.props"
:external="false" menu
:lang="locale.lang"
:hreflang="locale.lang"
rel="alternate"
:dir="locale.dir"
data-allow-mismatch="attribute"
/> />
<VPMenuGroup v-else :text="item.text" :items="item.items" />
</template> </template>
</ul> </ul>
<div <VPNavTranslations v-if="showTranslations" menu />
v-if="
site.appearance && <div v-if="showAppearance" class="group">
site.appearance !== 'force-dark' && <VPNavAppearance row />
site.appearance !== 'force-auto'
"
class="group"
>
<div class="item appearance">
<p class="label">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<div class="appearance-action">
<VPSwitchAppearance />
</div>
</div>
</div> </div>
<div v-if="theme.socialLinks" class="group"> <div v-if="showSocialLinks" class="group">
<div class="item social-links"> <div class="item social-links">
<VPSocialLinks class="social-links-list" :links="theme.socialLinks" /> <VPSocialLinks class="social-links-list" :links="theme.socialLinks!" />
</div> </div>
</div> </div>
</VPFlyout> </VPFlyout>
@ -83,35 +98,12 @@ const hasExtraContent = computed(
} }
} }
@media (min-width: 80rem) {
.VPNavBarExtra {
display: none;
}
}
.trans-title {
padding: 0 1.5rem 0 0.75rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.item.appearance,
.item.social-links { .item.social-links {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 0.75rem; padding: 0 0.75rem;
} }
.item.appearance {
min-width: 11rem;
}
.appearance-action {
margin-right: -0.125rem;
}
.social-links-list { .social-links-list {
margin: -0.25rem -0.5rem; margin: -0.25rem -0.5rem;
} }

@ -1,4 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useTemplateRef, watchEffect } from 'vue'
import { useData } from '../composables/data'
import { useNav } from '../composables/nav'
defineProps<{ defineProps<{
active: boolean active: boolean
}>() }>()
@ -6,19 +11,29 @@ defineProps<{
defineEmits<{ defineEmits<{
(e: 'click'): void (e: 'click'): void
}>() }>()
const { theme } = useData()
// register as the screen's trigger so Escape can return focus here
const el = useTemplateRef('el')
const { screenTriggerEl } = useNav()
watchEffect(() => {
screenTriggerEl.value = el.value
})
</script> </script>
<template> <template>
<button <button
ref="el"
type="button" type="button"
class="VPNavBarHamburger" class="VPNavBarHamburger"
:class="{ active }" :class="{ active }"
aria-label="mobile navigation" :aria-label="theme.mobileMenuLabel || 'Menu'"
:aria-expanded="active" :aria-expanded="active"
aria-controls="VPNavScreen"
@click="$emit('click')" @click="$emit('click')"
> >
<span class="container"> <span class="container" aria-hidden="true">
<span class="top" /> <span class="top" />
<span class="middle" /> <span class="middle" />
<span class="bottom" /> <span class="bottom" />
@ -60,7 +75,7 @@ defineEmits<{
.VPNavBarHamburger.active:hover .middle, .VPNavBarHamburger.active:hover .middle,
.VPNavBarHamburger.active:hover .bottom { .VPNavBarHamburger.active:hover .bottom {
background-color: var(--vp-c-text-2); background-color: var(--vp-c-text-2);
transition: top .25s, background-color .25s, transform .25s; transition: top 0.25s, background-color 0.25s, transform 0.25s;
} }
.top, .top,
@ -70,7 +85,7 @@ defineEmits<{
width: 1rem; width: 1rem;
height: 0.125rem; height: 0.125rem;
background-color: var(--vp-c-text-1); background-color: var(--vp-c-text-1);
transition: top .25s, background-color .5s, transform .25s; transition: top 0.25s, background-color 0.5s, transform 0.25s;
} }
.top { top: 0; left: 0; transform: translateX(0); } .top { top: 0; left: 0; transform: translateX(0); }

@ -1,46 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPNavBarMenuGroup from './VPNavBarMenuGroup.vue'
import VPNavBarMenuLink from './VPNavBarMenuLink.vue'
const { theme } = useData()
</script>
<template>
<nav
v-if="theme.nav"
aria-labelledby="main-nav-aria-label"
class="VPNavBarMenu"
>
<span id="main-nav-aria-label" class="visually-hidden">
Main Navigation
</span>
<ul class="list">
<li v-for="item in theme.nav" :key="JSON.stringify(item)">
<VPNavBarMenuLink v-if="'link' in item" :item />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
/>
<VPNavBarMenuGroup v-else :item />
</li>
</ul>
</nav>
</template>
<style scoped>
.VPNavBarMenu {
display: none;
}
.list {
display: flex;
}
@media (min-width: 48rem) {
.VPNavBarMenu {
display: block;
}
}
</style>

@ -1,53 +0,0 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import { isActive } from '../../shared'
import VPFlyout from './VPFlyout.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithChildren
}>()
const route = useRoute()
const isActiveGroup = computed(() => {
if (props.item.activeMatch) {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch,
true
)
}
return isChildActive(props.item)
})
function isChildActive(navItem: DefaultTheme.NavItem): boolean {
if ('component' in navItem) return false
if ('link' in navItem) {
const href =
typeof navItem.link === 'function'
? navItem.link(route.data)
: navItem.link
return isActive(
route.data.relativePath,
route.hash,
navItem.activeMatch || href,
!!navItem.activeMatch
)
}
return navItem.items.some(isChildActive)
}
</script>
<template>
<VPFlyout
:class="{ VPNavBarMenuGroup: true, active: isActiveGroup }"
:button="item.text"
:items="item.items"
/>
</template>

@ -1,62 +0,0 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import { isActive } from '../../shared'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
}>()
const route = useRoute()
const href = computed(() =>
typeof props.item.link === 'function'
? props.item.link(route.data)
: props.item.link
)
const isActiveLink = computed(() => {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch || href.value,
!!props.item.activeMatch
)
})
</script>
<template>
<VPLink
:class="{ VPNavBarMenuLink: true, active: isActiveLink }"
:href
:target="item.target"
:rel="item.rel"
:no-icon="item.noIcon"
tabindex="0"
>
<span v-html="item.text"></span>
</VPLink>
</template>
<style scoped>
.VPNavBarMenuLink {
display: flex;
align-items: center;
padding: 0 0.75rem;
line-height: var(--vp-nav-height);
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.VPNavBarMenuLink.active {
color: var(--vp-c-brand-1);
}
.VPNavBarMenuLink:hover {
color: var(--vp-c-brand-1);
}
</style>

@ -2,6 +2,7 @@
import { onKeyStroke } from '@vueuse/core' import { onKeyStroke } from '@vueuse/core'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed, defineAsyncComponent, onMounted, ref } from 'vue' import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { resolveMode, resolveOptionsForLanguage } from '../support/docsearch' import { resolveMode, resolveOptionsForLanguage } from '../support/docsearch'
import { smartComputed } from '../support/reactivity' import { smartComputed } from '../support/reactivity'
@ -203,7 +204,6 @@ function isEditingContent(event: KeyboardEvent): boolean {
@media (min-width: 48rem) { @media (min-width: 48rem) {
.VPNavBarSearch { .VPNavBarSearch {
gap: 0.5rem; gap: 0.5rem;
flex-grow: 1;
padding-left: 1.5rem; padding-left: 1.5rem;
} }
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save