mirror of https://github.com/vuejs/vitepress
commit
4d700cec32
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
@ -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,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
|
||||||
@ -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,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,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>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue