mirror of https://github.com/vuejs/vitepress
commit
7f1052d72b
@ -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/"')])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 283 B |
@ -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,89 @@
|
|||||||
|
import type { DefaultTheme } from 'vitepress/theme'
|
||||||
|
|
||||||
|
export const members: DefaultTheme.TeamMember[] = [
|
||||||
|
{
|
||||||
|
// smaller than the rendered avatar, checks that it still fills the circle
|
||||||
|
avatar: '/team-avatar-small.svg',
|
||||||
|
name: 'Alice Example',
|
||||||
|
title: 'Creator',
|
||||||
|
org: 'Example Org',
|
||||||
|
orgLink: 'https://example.com',
|
||||||
|
desc: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
|
||||||
|
links: [
|
||||||
|
{ icon: 'github', link: 'https://example.com' },
|
||||||
|
{ icon: 'x', link: 'https://example.com' }
|
||||||
|
],
|
||||||
|
sponsor: 'https://example.com'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// non-square, checks object-fit (side stripes must stay cropped out)
|
||||||
|
avatar: '/team-avatar-wide.svg',
|
||||||
|
name: 'Bob Example',
|
||||||
|
title: 'Maintainer',
|
||||||
|
links: [{ icon: 'github', link: 'https://example.com' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
avatar: '/team-avatar-small.svg',
|
||||||
|
name: 'Carol Example',
|
||||||
|
title: 'Partner'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const partners: DefaultTheme.TeamMember[] = [
|
||||||
|
{
|
||||||
|
avatar: '/team-avatar-wide.svg',
|
||||||
|
name: 'Dave Example',
|
||||||
|
title: 'Partner',
|
||||||
|
links: [{ icon: 'github', link: 'https://example.com' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
avatar: '/team-avatar-small.svg',
|
||||||
|
name: 'Eve Example',
|
||||||
|
title: 'Partner'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
avatar: '/team-avatar-wide.svg',
|
||||||
|
name: 'Frank Example',
|
||||||
|
title: 'Partner'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Sponsor {
|
||||||
|
name: string
|
||||||
|
img: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sponsor = (name: string): Sponsor => ({
|
||||||
|
name,
|
||||||
|
img: '/sponsor-logo.svg',
|
||||||
|
url: 'https://example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const sponsors: {
|
||||||
|
tier: string
|
||||||
|
size?: 'medium' | 'big'
|
||||||
|
items: Sponsor[]
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
tier: 'Platinum Sponsors',
|
||||||
|
size: 'big',
|
||||||
|
items: [sponsor('Sponsor One'), sponsor('Sponsor Two')]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'Gold Sponsors',
|
||||||
|
size: 'medium',
|
||||||
|
items: [
|
||||||
|
sponsor('Sponsor Three'),
|
||||||
|
sponsor('Sponsor Four'),
|
||||||
|
sponsor('Sponsor Five'),
|
||||||
|
sponsor('Sponsor Six')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const friends: Sponsor[] = [
|
||||||
|
sponsor('Friend One'),
|
||||||
|
sponsor('Friend Two'),
|
||||||
|
sponsor('Friend Three')
|
||||||
|
]
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
# Team & Sponsors in Doc Layout
|
||||||
|
|
||||||
|
<!--@include: ./parts/doc-page-body.md-->
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
title: Team & Sponsors in Home Layout (no markdown styles)
|
||||||
|
markdownStyles: false
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: Team & Sponsors
|
||||||
|
text: Home layout without markdown styles
|
||||||
|
tagline: Reference rendering of the components
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: Doc Layout
|
||||||
|
link: ./doc
|
||||||
|
image:
|
||||||
|
src: /vitepress.png
|
||||||
|
alt: VitePress
|
||||||
|
|
||||||
|
features:
|
||||||
|
- icon: 📝
|
||||||
|
title: Feature A
|
||||||
|
details: Some details about feature A.
|
||||||
|
- icon: ⚡
|
||||||
|
title: Feature B
|
||||||
|
details: Some details about feature B.
|
||||||
|
- icon: 🚀
|
||||||
|
title: Feature C
|
||||||
|
details: Some details about feature C.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--@include: ./parts/home-body.md-->
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
title: Team & Sponsors in Home Layout
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: Team & Sponsors
|
||||||
|
text: Home layout with markdown styles
|
||||||
|
tagline: Compare with the no-markdown-styles variant
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: Doc Layout
|
||||||
|
link: ./doc
|
||||||
|
image:
|
||||||
|
src: /vitepress.png
|
||||||
|
alt: VitePress
|
||||||
|
|
||||||
|
features:
|
||||||
|
- icon: 📝
|
||||||
|
title: Feature A
|
||||||
|
details: Some details about feature A.
|
||||||
|
- icon: ⚡
|
||||||
|
title: Feature B
|
||||||
|
details: Some details about feature B.
|
||||||
|
- icon: 🚀
|
||||||
|
title: Feature C
|
||||||
|
details: Some details about feature C.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--@include: ./parts/home-body.md-->
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
layout: page
|
||||||
|
title: Team & Sponsors in Page Layout
|
||||||
|
---
|
||||||
|
|
||||||
|
# Team & Sponsors in Page Layout
|
||||||
|
|
||||||
|
<!--@include: ./parts/doc-page-body.md-->
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { VPTeamMembers, VPSponsors } from 'vitepress/theme'
|
||||||
|
import { members, friends, sponsors } from './data'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
Regular paragraph with a [link](https://example.com) and `inline code`.
|
||||||
|
|
||||||
|
## Small, One Member
|
||||||
|
|
||||||
|
<VPTeamMembers size="small" :members="members.slice(0, 1)" />
|
||||||
|
|
||||||
|
## Small, Two Members
|
||||||
|
|
||||||
|
<VPTeamMembers size="small" :members="members.slice(0, 2)" />
|
||||||
|
|
||||||
|
## Small, Three Members
|
||||||
|
|
||||||
|
<VPTeamMembers size="small" :members />
|
||||||
|
|
||||||
|
## Medium, One Member
|
||||||
|
|
||||||
|
<VPTeamMembers size="medium" :members="members.slice(0, 1)" />
|
||||||
|
|
||||||
|
## Medium, Two Members
|
||||||
|
|
||||||
|
<VPTeamMembers size="medium" :members="members.slice(0, 2)" />
|
||||||
|
|
||||||
|
## Medium, Three Members
|
||||||
|
|
||||||
|
<VPTeamMembers size="medium" :members />
|
||||||
|
|
||||||
|
## Sponsors, Tiered
|
||||||
|
|
||||||
|
<VPSponsors :data="sponsors" />
|
||||||
|
|
||||||
|
## Sponsors, Single Tier
|
||||||
|
|
||||||
|
<VPSponsors tier="Friends" size="mini" :data="friends" />
|
||||||
|
|
||||||
|
## After Components
|
||||||
|
|
||||||
|
Trailing markdown content after the components.
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
VPTeamPage,
|
||||||
|
VPTeamPageTitle,
|
||||||
|
VPTeamPageSection,
|
||||||
|
VPTeamMembers,
|
||||||
|
VPHomeSponsors
|
||||||
|
} from 'vitepress/theme'
|
||||||
|
import { members, partners, sponsors } from './data'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
## Markdown Section
|
||||||
|
|
||||||
|
Some paragraph text with a [link](https://example.com) and `inline code` to
|
||||||
|
verify how markdown content is styled in this variant.
|
||||||
|
|
||||||
|
- List item one
|
||||||
|
- List item two
|
||||||
|
|
||||||
|
<VPTeamPage>
|
||||||
|
<VPTeamPageTitle>
|
||||||
|
<template #title>Our Team</template>
|
||||||
|
<template #lead>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||||
|
tempor incididunt.
|
||||||
|
</template>
|
||||||
|
</VPTeamPageTitle>
|
||||||
|
<VPTeamMembers :members="members.slice(0, 2)" />
|
||||||
|
<VPTeamPageSection>
|
||||||
|
<template #title>Partners</template>
|
||||||
|
<template #lead>Lorem ipsum dolor sit amet.</template>
|
||||||
|
<template #members>
|
||||||
|
<VPTeamMembers size="small" :members="partners" />
|
||||||
|
</template>
|
||||||
|
</VPTeamPageSection>
|
||||||
|
</VPTeamPage>
|
||||||
|
|
||||||
|
<VPHomeSponsors
|
||||||
|
message="Made possible by our generous sponsors"
|
||||||
|
:data="sponsors"
|
||||||
|
action-text="Become a sponsor"
|
||||||
|
action-link="https://example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
## After Components
|
||||||
|
|
||||||
|
Trailing markdown content after the components.
|
||||||
@ -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,115 +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, createRequire } 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'
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
const pkg = require('./package.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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue