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