Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions __tests__/e2e/sidebar.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
56 changes: 56 additions & 0 deletions __tests__/unit/shared/shared.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
})
10 changes: 5 additions & 5 deletions docs/en/reference/default-theme-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ Example result:

Alternatively, you can use [Algolia DocSearch](#algolia-search) or some community plugins like:

- <https://www.npmjs.com/package/vitepress-plugin-search>
- <https://www.npmjs.com/package/vitepress-plugin-pagefind>
- <https://www.npmjs.com/package/@orama/plugin-vitepress>
- <https://www.npmjs.com/package/vitepress-plugin-typesense>
- <https://www.npmjs.com/package/vitepress-plugin-cloudflare-ai-search>
- <https://npmx.dev/package/vitepress-plugin-pagefind>
- <https://npmx.dev/package/vitepress-plugin-typesense>
- <https://npmx.dev/package/vitepress-plugin-cloudflare-ai-search>

<!-- - <https://npmx.dev/package/@orama/plugin-vitepress> -- replace with zbsearch one when published -->

### i18n {#local-search-i18n}

Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/frontmatter-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ description: VitePress

- Type: `HeadConfig[]`

Specify extra head tags to be injected for the current page. Will be appended after head tags injected by site-level config.
Specify extra head tags to be injected for the current page. They are [merged](./site-config#head) with the head tags injected by site-level config.

```yaml
---
Expand Down
7 changes: 7 additions & 0 deletions docs/en/reference/site-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,13 @@ type HeadConfig =
| [string, Record<string, string>, string]
```

Head entries from the site config, [locale config](../guide/i18n), [directory-level config](#directory-level-overrides), [frontmatter](./frontmatter-config#head) and [`transformHead`](#transformhead) are merged in that order. A later entry replaces an earlier one with the same key instead of being appended:

- Any element with an `id` attribute is keyed by its `id`.
- A `meta` element without an `id` is keyed by its first attribute other than `content` (e.g. `name`, `property`, `http-equiv`) and that attribute's value.

Other elements are never deduplicated. To render multiple `meta` tags that would share a key, like several `<meta name="author">`, give each of them a unique `id`.

#### Example: Adding a favicon

```ts
Expand Down
49 changes: 14 additions & 35 deletions src/client/theme-default/components/VPSidebarItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,16 @@ const {
toggle
} = useSidebarItemControl(computed(() => props.item))

const sectionTag = computed(() => (hasChildren.value ? 'section' : `div`))

const linkTag = computed(() => (isLink.value ? 'a' : 'div'))

const textTag = computed(() => {
return !hasChildren.value
? 'p'
: props.depth + 2 === 7
? 'p'
: `h${props.depth + 2}`
})
const textTag = computed(() =>
hasChildren.value && props.depth < 5 ? `h${props.depth + 2}` : 'p'
)

const itemRole = computed(() => (isLink.value ? undefined : 'button'))
// a section needs a heading
const sectionTag = computed(() =>
props.item.text && textTag.value !== 'p' ? 'section' : 'div'
)

const classes = computed(() => [
[`level-${props.depth}`],
Expand All @@ -42,31 +39,14 @@ const classes = computed(() => [
{ 'has-active': hasActiveLink.value }
])

function onItemInteraction(e: MouseEvent | Event) {
if ('key' in e && e.key !== 'Enter') {
return
}
function onItemClick() {
!props.item.link && toggle()
}

function onCaretClick() {
props.item.link && toggle()
}
</script>

<template>
<component :is="sectionTag" class="VPSidebarItem" :class="classes">
<div
v-if="item.text"
class="item"
:role="itemRole"
v-on="
item.items
? { click: onItemInteraction, keydown: onItemInteraction }
: {}
"
:tabindex="item.items && 0"
>
<div v-if="item.text" class="item" @click="onItemClick">
<div class="indicator" />

<VPLink
Expand All @@ -81,17 +61,16 @@ function onCaretClick() {
</VPLink>
<component v-else :is="textTag" class="text" v-html="item.text" />

<div
<button
v-if="item.collapsed != null && item.items && item.items.length"
type="button"
class="caret"
role="button"
aria-label="toggle section"
@click="onCaretClick"
@keydown.enter="onCaretClick"
tabindex="0"
:aria-expanded="!collapsed"
@click.stop="toggle"
>
<span class="vpi-chevron-right caret-icon" />
</div>
</button>
</div>

<ul v-if="item.items && item.items.length" class="items">
Expand Down
8 changes: 4 additions & 4 deletions src/client/theme-default/components/VPTeamMembersItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ withDefaults(defineProps<Props>(), {
</script>

<template>
<article class="VPTeamMembersItem" :class="[size]">
<div class="VPTeamMembersItem" :class="[size]">
<div class="profile">
<figure class="avatar">
<img class="avatar-img" :src="member.avatar" :alt="member.name" />
</figure>
<div class="data">
<h1 class="name">
<p class="name">
{{ member.name }}
</h1>
</p>
<p v-if="member.title || member.org" class="affiliation">
<span v-if="member.title" class="title">
{{ member.title }}
Expand All @@ -49,7 +49,7 @@ withDefaults(defineProps<Props>(), {
<span class="vpi-heart sp-icon" /> {{ member.actionText || 'Sponsor' }}
</VPLink>
</div>
</article>
</div>
</template>

<style scoped>
Expand Down
22 changes: 15 additions & 7 deletions src/shared/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,25 +198,23 @@ function createTitleTemplate(

export function mergeHead(...headArrays: HeadConfig[][]): HeadConfig[] {
const merged: HeadConfig[] = []
const metaKeyMap = new Map<string, number>()
const keyMap = new Map<string, number>()

for (const current of headArrays) {
for (const tag of current) {
const [type, attrs] = tag
const keyAttr = Object.entries(attrs)[0]
const key = getHeadKey(tag)

if (type !== 'meta' || !keyAttr) {
if (key == null) {
merged.push(tag)
continue
}

const key = `${keyAttr[0]}=${keyAttr[1]}`
const existingIndex = metaKeyMap.get(key)
const existingIndex = keyMap.get(key)

if (existingIndex != null) {
merged[existingIndex] = tag // replace existing tag
} else {
metaKeyMap.set(key, merged.length)
keyMap.set(key, merged.length)
merged.push(tag)
}
}
Expand All @@ -225,6 +223,16 @@ export function mergeHead(...headArrays: HeadConfig[][]): HeadConfig[] {
return merged
}

// any element is keyed by its `id`; a meta tag without one is keyed by its
// first attribute other than `content` (e.g. `name`, `property`, `http-equiv`)
function getHeadKey([type, attrs]: HeadConfig): string | undefined {
if (attrs.id) return `id=${attrs.id}`
if (type !== 'meta') return
for (const name in attrs) {
if (name !== 'content') return `${name}=${attrs[name]}`
}
}

export function sanitizeFileName(name: string): string {
const match = DRIVE_LETTER_REGEX.exec(name)
const driveLetter = match ? match[0] : ''
Expand Down