Merge upstream main into feat/disable-auto-html-dir

pull/5416/head
Achord Chan 2 weeks ago
commit dae648e8d4

@ -23,7 +23,7 @@ Hi! We're really excited that you are interested in contributing to VitePress. B
## Development Setup
You will need [Node.js](https://nodejs.org) v20 or higher and [pnpm](https://pnpm.io).
You will need [Node.js](https://nodejs.org) v22.22.1 or higher and [pnpm](https://pnpm.io).
After cloning the repo, run:

@ -1,3 +1,47 @@
## [2.0.0-alpha.20](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.19...v2.0.0-alpha.20) (2026-09-04)
### Bug Fixes
- harden eager frontmatter interpolation ([#5413](https://github.com/vuejs/vitepress/issues/5413)) ([09f9672](https://github.com/vuejs/vitepress/commit/09f9672ee108578808f793bb57e306bc92dff7bc))
- key head entries by id and ignore meta content when deduping ([b18f306](https://github.com/vuejs/vitepress/commit/b18f30680b9bced83abd6eacb9417701298cb309)), closes [#5362](https://github.com/vuejs/vitepress/issues/5362) [#5363](https://github.com/vuejs/vitepress/issues/5363) [#5379](https://github.com/vuejs/vitepress/issues/5379)
- **search:** respect per-locale markdown options ([#5351](https://github.com/vuejs/vitepress/issues/5351)) ([27c4a1a](https://github.com/vuejs/vitepress/commit/27c4a1a2a3a098a00e332a5db26f3d2d79aa319a))
- serialize config functions as code, not strings revived via new Function (close [#3685](https://github.com/vuejs/vitepress/issues/3685)) ([d3b2957](https://github.com/vuejs/vitepress/commit/d3b2957db000772a408943d01382650821ce0950))
- **theme/regression:** fix misaligned external link icon ([2be52f0](https://github.com/vuejs/vitepress/commit/2be52f0657efb6e7e061cf29261158bd99f7ff86))
- **theme:** adjust z-index for VPNavBar and simplify VPNavScreen styles ([#5374](https://github.com/vuejs/vitepress/issues/5374)) ([35cf647](https://github.com/vuejs/vitepress/commit/35cf6470d36bfea5f7c53de251382a19275f72a9))
- **theme:** cover the external link icon's paint box with its mask ([39b8f9f](https://github.com/vuejs/vitepress/commit/39b8f9f00140cc3f4a60c9e1ffb0c142de5ea089))
- **theme:** drop the -webkit-backdrop-filter fallbacks ([a2a0a92](https://github.com/vuejs/vitepress/commit/a2a0a923e5f468af1bb81023347c61513d6c408a))
- **theme:** fix menu dropdown group dividers ([#5365](https://github.com/vuejs/vitepress/issues/5365)) ([49c84f5](https://github.com/vuejs/vitepress/commit/49c84f570a933a2b5a95411bfd419b7369ab02a7))
- **theme:** fix nav screen and divider appearance ([#5369](https://github.com/vuejs/vitepress/issues/5369)) ([a0401ee](https://github.com/vuejs/vitepress/commit/a0401eed4b6aa70fcab7c2c9a2d2aeaa674f59bd))
- **theme:** fix social links spacing in dropdown ([#5370](https://github.com/vuejs/vitepress/issues/5370)) ([cb636b2](https://github.com/vuejs/vitepress/commit/cb636b241075296910237d6c067cba8a9052683e))
- **theme:** handle outline bottom overscroll ([#5375](https://github.com/vuejs/vitepress/issues/5375)) ([3fe901d](https://github.com/vuejs/vitepress/commit/3fe901dea973ce3b24c1f3d38f313ab40466357e))
- **theme:** keep search beside the title without a nav menu ([14f4f09](https://github.com/vuejs/vitepress/commit/14f4f09d32a5325a33629b5911c714d44882a726))
- **theme:** keep the search keycap glyphs out of the DOM text ([60f656b](https://github.com/vuejs/vitepress/commit/60f656b0ec46a4bbe61f877036385ec3af64a9ff)), closes [#5401](https://github.com/vuejs/vitepress/issues/5401)
- **theme:** mark current navigation links ([#5395](https://github.com/vuejs/vitepress/issues/5395)) ([0f0fe13](https://github.com/vuejs/vitepress/commit/0f0fe135762489f1a6c73348bd8b0a86db969248))
- **theme:** paint the navbar divider on its own layer ([9bebd09](https://github.com/vuejs/vitepress/commit/9bebd092ebad1818e31cac8e86e5b03704554995)), closes [#5399](https://github.com/vuejs/vitepress/issues/5399)
- **theme:** prevent layout shifts when locking page scroll ([310679a](https://github.com/vuejs/vitepress/commit/310679a76c43b429dbe8b7127dc7e6bb3ec50569)), closes [#5386](https://github.com/vuejs/vitepress/issues/5386)
- **theme:** prevent scroll chaining in local nav outline dropdown ([#5367](https://github.com/vuejs/vitepress/issues/5367)) ([0a96bae](https://github.com/vuejs/vitepress/commit/0a96bae50b907b70a3cd712f98514ce77086496a))
- **theme:** remove extra margins from first and last paragraph inside a list element ([8dbd782](https://github.com/vuejs/vitepress/commit/8dbd782c752ed510a11c04527bed974c122e8133)), closes [#5353](https://github.com/vuejs/vitepress/issues/5353)
- **theme:** render sidebar active state during ssr ([005aa5c](https://github.com/vuejs/vitepress/commit/005aa5c3e88d5d6a455bcc20b4c4e6f2c3a1abe9))
- **theme:** render sidebar group toggles as native buttons ([ed2bfb2](https://github.com/vuejs/vitepress/commit/ed2bfb266ef788e86d1a73fe3cd7708a4cd5e260)), closes [#5366](https://github.com/vuejs/vitepress/issues/5366) [#5371](https://github.com/vuejs/vitepress/issues/5371)
- **theme:** rendering of sponsor and team components inside vp-doc containers ([#4493](https://github.com/vuejs/vitepress/issues/4493)) ([b7602df](https://github.com/vuejs/vitepress/commit/b7602df1cdee75d0814cf3f131b4906a31841851))
- **theme:** treat negative scrollY as top ([#5368](https://github.com/vuejs/vitepress/issues/5368)) ([9d521db](https://github.com/vuejs/vitepress/commit/9d521db8671a53d5a5f23baf5226879ce2d25960))
- **theme:** use a getter as VPSidebar's open watch source ([9aeeb4f](https://github.com/vuejs/vitepress/commit/9aeeb4fa31396040b6014af2b45111a05751547d))
- **theme:** use relative length units to auto-close nav screen menu ([#5387](https://github.com/vuejs/vitepress/issues/5387)) ([c197979](https://github.com/vuejs/vitepress/commit/c1979799936f68443f150122d427fb26ff30e9ae))
### Features
- **cli:** make shortcuts case-insensitive ([#5378](https://github.com/vuejs/vitepress/issues/5378)) ([4ffcdae](https://github.com/vuejs/vitepress/commit/4ffcdae825b7534c24bc9ab4473849c0b8d72669))
- hashed icon styles, offline dev icons, arbitrary iconify collections ([#5407](https://github.com/vuejs/vitepress/issues/5407)) ([d00a5e0](https://github.com/vuejs/vitepress/commit/d00a5e0f8778923d7ef6adf97b5671e9d0f86be6))
- relative base (`./`) and `assetsBase` (CDN prefix) ([#5406](https://github.com/vuejs/vitepress/issues/5406)) ([feadd9f](https://github.com/vuejs/vitepress/commit/feadd9fcc1519d52a74940f8cd15a71ffcc25fe8))
- resolve `$frontmatter` expressions while rendering markdown ([#5412](https://github.com/vuejs/vitepress/issues/5412)) ([a1eb284](https://github.com/vuejs/vitepress/commit/a1eb28496e67f881fc50da776c11aa45a73c4f40))
- **ssr:** enable `throwUnhandledErrorInProduction` ([#5419](https://github.com/vuejs/vitepress/issues/5419)) ([a6573ff](https://github.com/vuejs/vitepress/commit/a6573ff60c4b85d5473ea3903b223f8f1ee9f412))
- **theme:** add --vp-local-nav-divider-color ([110209d](https://github.com/vuejs/vitepress/commit/110209dd32bd7b828abccfb773ae2207f36c1147))
- **theme:** add opt-in severity-based colors for containers, alerts, and badges ([#5373](https://github.com/vuejs/vitepress/issues/5373)) ([45cb85e](https://github.com/vuejs/vitepress/commit/45cb85e5104346422e82f9cae96b0ce0afc690d8))
- **theme:** redesign the navbar ([#5397](https://github.com/vuejs/vitepress/issues/5397)) ([26b76d6](https://github.com/vuejs/vitepress/commit/26b76d6d9e8fd3ae368c7a1e8ad6698cdeb73e7a))
- **theme:** scroll toc if active item is out of view ([#5377](https://github.com/vuejs/vitepress/issues/5377)) ([72534af](https://github.com/vuejs/vitepress/commit/72534af30ebe25cfedf7c3ca6d7f9fbfbbaa487f))
- **theme:** un-deprecate Theme.setup and compose it across extends ([#5404](https://github.com/vuejs/vitepress/issues/5404)) ([ebbd48c](https://github.com/vuejs/vitepress/commit/ebbd48c8c76a1f794ed52d8d4d9917f1bd52bea0))
- **theme:** use relative length units ([#5323](https://github.com/vuejs/vitepress/issues/5323)) ([2a91caa](https://github.com/vuejs/vitepress/commit/2a91caa78e62227764e8714574b8367e0610e8ee))
## [2.0.0-alpha.19](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.18...v2.0.0-alpha.19) (2026-08-02)
### Bug Fixes

@ -6,7 +6,7 @@ VitePress is published under the MIT license (see LICENSE). The published vitepr
License: MIT
By: Anthony Fu <anthonyfu117@hotmail.com>
Repository: https://github.com/antfu/install-pkg
Repository: https://github.com/antfu-collective/install-pkg
> MIT License
>

@ -5,10 +5,11 @@ const mode = process.env.VP_TEST_MODE || 'relative'
export default defineConfig({
title: 'Base Fixture',
description: 'Fixture site for base/assetsBase behavior',
base: mode === 'plain' || mode === 'cdn' ? '/' : './',
base: ['plain', 'cdn', 'sharded'].includes(mode) ? '/' : './',
assetsBase:
mode === 'cdn' ? `http://localhost:${process.env.VP_CDN_PORT}/` : undefined,
mpa: mode === 'mpa',
assetsShards: mode === 'sharded' ? 3 : undefined,
outDir: `.vitepress/dist-${mode}`,
cleanUrls: false,
rewrites: { 'src-moved.md': 'moved/target.md' },

@ -0,0 +1,119 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
const dir = resolve(fileURLToPath(import.meta.url), '..')
const dist = (...p: string[]) =>
join(dir, 'fixture/.vitepress/dist-sharded', ...p)
const origin = () => `http://localhost:${process.env['SHARDED_PORT']}`
// output-relative paths of everything under assets/
const files = () =>
readdirSync(dist('assets'), { recursive: true, withFileTypes: true })
.filter((e) => e.isFile())
.map((e) =>
join(e.parentPath, e.name)
.slice(dist('assets').length + 1)
.split(sep)
.join('/')
)
const isShardedPath = (f: string) => /^[0-2]\/[^/]+$/.test(f)
describe('assetsShards emit', () => {
test('page chunks land in numbered subdirectories', () => {
const pages = files().filter((f) => /\.md\.[\w-]+(\.lean)?\.js$/.test(f))
expect(pages.length).toBeGreaterThan(0)
expect(pages.every(isShardedPath)).toBe(true)
})
test('imported assets are sharded too, shared chunks and the app are not', () => {
const all = files()
const assets = all.filter(
(f) => /\.(png|woff2|css)$/.test(f) && !/^vp-icons\./.test(f)
)
expect(assets.length).toBeGreaterThan(0)
expect(assets.every(isShardedPath)).toBe(true)
expect(all.some((f) => /^chunks\/framework\.[\w-]+\.js$/.test(f))).toBe(
true
)
expect(all.some((f) => /^app\.[\w-]+\.js$/.test(f))).toBe(true)
})
test('hash map entries carry the shard and resolve to real files', () => {
const map: Record<string, string> = JSON.parse(
readFileSync(dist('hashmap.json'), 'utf-8')
)
// hash map keys are lowercased page names, file names keep their case
const lower = new Set(files().map((f) => f.toLowerCase()))
expect(Object.keys(map).length).toBeGreaterThan(0)
for (const [page, entry] of Object.entries(map)) {
expect(entry).toMatch(/^[0-2]\/[\w-]+$/)
const [shard, hash] = entry.split('/')
const chunk = `${shard}/${page}.${hash}`.toLowerCase()
expect(lower.has(`${chunk}.js`)).toBe(true)
expect(lower.has(`${chunk}.lean.js`)).toBe(true)
}
})
test('preload links point at files that exist', () => {
const existing = new Set(files().map((f) => `assets/${f}`))
for (const page of [
'index',
'sub/page',
'sub/deep/page2',
'moved/target'
]) {
const links = [
...readFileSync(dist(`${page}.html`), 'utf-8').matchAll(
/<link rel="modulepreload" href="\/([^"]+)">/g
)
].map((m) => m[1]!)
expect(links.some((l) => l.includes('.md.'))).toBe(true)
for (const link of links) expect(existing.has(link)).toBe(true)
}
})
})
describe('assetsShards in the browser', () => {
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
test('pages hydrate with sharded chunks', async () => {
await t.page.goto(`${origin()}/`)
await waitForHydration(t.page)
expect(await t.page.textContent('h1')).toContain('Home')
})
test('client-side navigation loads page chunks from their shard', async () => {
await t.page.evaluate(() => ((window as any).__spa_marker = 1))
await t.page.click('.vp-doc a[href="/sub/page.html"]')
await t.page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('Sub page')
)
expect(
await t.page.evaluate(() => (window as any).__spa_marker === 1)
).toBe(true)
const chunk = await t.page.evaluate(() =>
performance
.getEntriesByType('resource')
.map((r) => r.name)
.find((n) => /\/assets\/\d+\/sub_page\.md\.[\w-]+\.js$/.test(n))
)
expect(chunk).toBeDefined()
})
test('no console or page errors across the whole flow', () => {
expect(realErrors(t.errors)).toEqual([])
})
})

@ -64,7 +64,7 @@ export async function setup() {
// one process per flavor: the markdown renderer is a module-level
// singleton, so in-process builds would leak the first base into the rest
for (const mode of ['plain', 'relative', 'cdn', 'mpa']) {
for (const mode of ['plain', 'relative', 'cdn', 'mpa', 'sharded']) {
// mpa builds never empty outDir, so stale assets would survive reruns
await rm(dist(mode), { recursive: true, force: true })
const res = spawnSync(process.execPath, [bin, 'build', 'fixture'], {
@ -91,7 +91,8 @@ export async function setup() {
false
),
await serveStatic([['/', dist('cdn')]], false),
cdnServer
cdnServer,
await serveStatic([['/', dist('sharded')]], false)
]
browserServer = await chromium.launchServer({
@ -105,6 +106,7 @@ export async function setup() {
process.env['SUB_PORT'] = String(portOf(servers[0]!))
process.env['PAGES_PORT'] = String(portOf(servers[1]!))
process.env['VP_CDN_PORT'] = String(cdnPort)
process.env['SHARDED_PORT'] = String(portOf(servers[3]!))
}
export async function teardown() {

@ -0,0 +1,10 @@
import { readFile } from 'node:fs/promises'
export default {
watch: '../../fixtures/external-data/**/*.json',
async load(files: string[]) {
return Promise.all(
files.map(async (file) => JSON.parse(await readFile(file, 'utf-8')))
)
}
}

@ -0,0 +1,7 @@
<script setup>
import { data } from './files.data'
</script>
# External data
<pre id="external-data">{{ JSON.stringify(data) }}</pre>

@ -0,0 +1,45 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
test('loads data outside the site root', async () => {
await goto('/external-data/')
expect(await page.textContent('pre#external-data')).toBe('[{"a":true}]')
})
test.runIf(!process.env.VITE_TEST_BUILD)(
'updates external data when files change, are added, or are deleted',
async () => {
await goto('/external-data/')
const a = fileURLToPath(
new URL('../../fixtures/external-data/a.json', import.meta.url)
)
const nested = fileURLToPath(
new URL('../../fixtures/external-data/nested/', import.meta.url)
)
const original = await readFile(a, 'utf-8')
async function expectData(data: unknown) {
await page.waitForFunction(
(expected) =>
document.querySelector('pre#external-data')?.textContent === expected,
JSON.stringify(data)
)
}
try {
await writeFile(a, '{"a":false}\n')
await expectData([{ a: false }])
await mkdir(nested)
await writeFile(path.join(nested, 'b.json'), '{"b":true}\n')
await expectData([{ a: false }, { b: true }])
await rm(nested, { recursive: true })
await expectData([{ a: false }])
} finally {
await rm(nested, { recursive: true, force: true })
await writeFile(a, original)
}
}
)

@ -0,0 +1,30 @@
describe('navbar layout', () => {
test.each([959, 960, 1440])(
'reserves space beside the title only for a visible sidebar at %ipx',
async (width) => {
await page.setViewportSize({ width, height: 720 })
const searchOffset = async (path: string) => {
await goto(path)
await page.evaluate(() => document.fonts.ready)
const title = await page.locator('.VPNavBarTitle').boundingBox()
const search = await page.locator('.VPNavBarSearch').boundingBox()
return search!.x - title!.x
}
const homeOffset = await searchOffset('/')
for (const path of ['/navbar/no-sidebar', '/missing-page']) {
expect(await searchOffset(path)).toBeCloseTo(homeOffset, 0)
}
const sidebarOffset = await searchOffset('/home')
if (width >= 960) {
expect(sidebarOffset).toBeGreaterThan(homeOffset)
} else {
expect(sidebarOffset).toBeCloseTo(homeOffset, 0)
}
}
)
})

@ -0,0 +1,7 @@
---
sidebar: false
---
# Page without a sidebar
The navigation title should keep its natural width when the sidebar is hidden.

@ -10,7 +10,7 @@
"site:preview": "vitepress preview"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.126",
"@iconify-json/lucide": "^1.2.131",
"vitepress": "workspace:*"
}
}

@ -0,0 +1 @@
# HTML link destination

@ -0,0 +1,15 @@
---
sidebar: false
outline: false
---
# Link prefetching
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="150">
<a href="./svg.html"><text x="10" y="25">SVG link</text></a>
<a href="./svg.html#section"><text x="10" y="50">Same page, another hash</text></a>
<a xlink:href="./xlink.html"><text x="10" y="75">SVG xlink</text></a>
<a href="./new-tab.html" target="_blank"><text x="10" y="100">New tab</text></a>
</svg>
[HTML link](./html.md)

@ -0,0 +1 @@
# New tab destination

@ -0,0 +1,43 @@
test.runIf(process.env.VITE_TEST_BUILD)(
'prefetches SVG links without errors and respects new-tab targets',
async () => {
const errors: string[] = []
const onPageError = (error: Error) => errors.push(error.message)
page.on('pageerror', onPageError)
try {
await goto('/prefetch/')
await expect
.poll(
async () => ({
errors,
pages: await page
.locator('link[rel="prefetch"]')
.evaluateAll((links) =>
links
.map(
(link) =>
link
.getAttribute('href')
?.match(/prefetch_(.*?)\.md\./)?.[1]
)
.filter(Boolean)
.sort()
)
}),
{ timeout: 10_000 }
)
.toEqual({ errors: [], pages: ['html', 'svg', 'xlink'] })
await page.locator('svg a[href="./svg.html"]').click()
await page.waitForSelector('h1', { state: 'visible' })
await expect
.poll(() => page.locator('h1').textContent())
.toContain('SVG link destination')
expect(errors).toEqual([])
} finally {
page.off('pageerror', onPageError)
}
}
)

@ -0,0 +1,3 @@
# SVG link destination
## Section

@ -0,0 +1 @@
# SVG xlink destination

@ -0,0 +1,53 @@
---
dir: rtl
---
# RTL Page
با `--flag` اجرا کنید و `useData()` را بخوانید. این یک [پیوند بیرونی](https://vitepress.dev/) است.
## Section One
```js:line-numbers
const a = 1
```
> A blockquote keeps its bar on the reading side.
| Column | Value |
| ------ | ----- |
| a | 1 |
Filler paragraph one. Filler paragraph one. Filler paragraph one. Filler paragraph one.
Filler paragraph two. Filler paragraph two. Filler paragraph two. Filler paragraph two.
Filler paragraph three. Filler paragraph three. Filler paragraph three. Filler paragraph three.
Filler paragraph four. Filler paragraph four. Filler paragraph four. Filler paragraph four.
Filler paragraph five. Filler paragraph five. Filler paragraph five. Filler paragraph five.
Filler paragraph six. Filler paragraph six. Filler paragraph six. Filler paragraph six.
Filler paragraph seven. Filler paragraph seven. Filler paragraph seven. Filler paragraph seven.
Filler paragraph eight. Filler paragraph eight. Filler paragraph eight. Filler paragraph eight.
## Section Two
Filler paragraph nine. Filler paragraph nine. Filler paragraph nine. Filler paragraph nine.
Filler paragraph ten. Filler paragraph ten. Filler paragraph ten. Filler paragraph ten.
Filler paragraph eleven. Filler paragraph eleven. Filler paragraph eleven. Filler paragraph eleven.
Filler paragraph twelve. Filler paragraph twelve. Filler paragraph twelve. Filler paragraph twelve.
Filler paragraph thirteen. Filler paragraph thirteen. Filler paragraph thirteen. Filler paragraph thirteen.
Filler paragraph fourteen. Filler paragraph fourteen. Filler paragraph fourteen. Filler paragraph fourteen.
Filler paragraph fifteen. Filler paragraph fifteen. Filler paragraph fifteen. Filler paragraph fifteen.
Filler paragraph sixteen. Filler paragraph sixteen. Filler paragraph sixteen. Filler paragraph sixteen.

@ -0,0 +1,154 @@
const box = (selector: string) =>
page
.locator(selector)
.first()
.evaluate((el) => {
const { x, y, width, height } = el.getBoundingClientRect()
return { x, y, width, height, right: x + width, bottom: y + height }
})
// screen positions of the first and last characters of an element's text,
// which is how the bidi algorithm's reordering shows up
const glyphEnds = (selector: string) =>
page
.locator(selector)
.first()
.evaluate((el) => {
const node = el.firstChild as Text
const range = document.createRange()
range.setStart(node, 0)
range.setEnd(node, 1)
const first = range.getBoundingClientRect().x
range.setStart(node, node.length - 1)
range.setEnd(node, node.length)
const last = range.getBoundingClientRect().x
return { first, last }
})
// scrolls a heading into view until the outline marks it active. The scroll
// is re-issued on every check, so a lost scroll event or a dev-server reload
// triggered by another spec cannot leave the wait hanging (seen on Windows CI)
const activateHeading = (id: string) =>
page.waitForFunction((id) => {
if (document.querySelector(`.outline-link.active[href="#${id}"]`)) {
return true
}
document.getElementById(id)?.scrollIntoView()
return false
}, id)
describe('rtl', () => {
beforeAll(async () => {
await goto('/rtl/')
})
afterAll(async () => {
await page.setViewportSize({ width: 1280, height: 720 })
})
test('sets the direction on the html element', async () => {
expect(await page.getAttribute('html', 'dir')).toBe('rtl')
if (process.env['VITE_TEST_BUILD']) {
const html = await (await page.request.get(page.url())).text()
expect(html).toContain('<html lang="en-US" dir="rtl">')
}
})
test('lays the page out from the right', async () => {
const width = await page.evaluate(() => innerWidth)
const sidebar = await box('.VPSidebar')
expect(sidebar.right).toBeGreaterThan(width - 1)
expect(sidebar.x).toBeGreaterThan(width / 2)
const heading = await box('.vp-doc h1')
const title = await glyphEnds('.vp-doc h1')
expect(title.last).toBeGreaterThan(heading.x + heading.width / 2)
const anchor = await box('.vp-doc h2 .header-anchor')
const h2 = await box('.vp-doc h2')
expect(anchor.x).toBeGreaterThan(h2.x + h2.width / 2)
})
test('keeps the outline marker on the reading side and moving', async () => {
const outline = await box('.VPDocAsideOutline .content')
await activateHeading('section-one')
const before = await box('.outline-marker')
expect(before.x).toBeGreaterThan(outline.x + outline.width / 2)
await activateHeading('section-two')
const after = await box('.outline-marker')
expect(after.y).toBeGreaterThan(before.y)
expect(Math.abs(after.x - before.x)).toBeLessThan(1)
})
test('mirrors the sidebar caret when a group collapses', async () => {
const group = page.locator('.VPSidebarItem.level-0.collapsible').first()
await group.locator('.caret').first().click()
expect(
await group.evaluate((el) => el.classList.contains('collapsed'))
).toBe(true)
expect(
await group
.locator('.caret-icon')
.first()
.evaluate((el) => getComputedStyle(el).scale)
).toBe('-1 1')
await group.locator('.caret').first().click()
})
test('keeps code left-to-right', async () => {
const wrapper = page.locator('.vp-doc div[class*="language-"]').first()
expect(await wrapper.getAttribute('dir')).toBe('ltr')
expect(
await wrapper
.locator('pre')
.evaluate((el) => getComputedStyle(el).direction)
).toBe('ltr')
const block = await box('.vp-doc div[class*="language-"]')
const copy = await box('.vp-doc div[class*="language-"] > button.copy')
const gutter = await box(
'.vp-doc div[class*="language-"] > .line-numbers-wrapper'
)
expect(copy.x).toBeGreaterThan(block.x + block.width / 2)
expect(gutter.right).toBeLessThan(block.x + block.width / 2)
const flag = await glyphEnds('.vp-doc p code')
expect(flag.first).toBeLessThan(flag.last)
})
test('mirrors the external link icon', async () => {
const mask = await page
.locator('.vp-doc a[href^="https://"]')
.first()
.evaluate((el) => {
const style = getComputedStyle(el, '::after')
return style.maskImage || style.webkitMaskImage
})
expect(mask).toContain('matrix(-1 0 0 1 24 0)')
})
test('slides the mobile sidebar in from the right', async () => {
await page.setViewportSize({ width: 375, height: 812 })
await goto('/rtl/')
const viewport = await page.evaluate(
() => document.documentElement.clientWidth
)
const closed = await box('.VPSidebar')
expect(closed.x).toBeGreaterThanOrEqual(viewport - 1)
// open the sidebar and wait for its slide-in to settle against the right
// edge, reopening it if a reload closed it meanwhile (see activateHeading)
await page.waitForFunction(() => {
const sidebar = document.querySelector('.VPSidebar')!
if (!sidebar.classList.contains('open')) {
document.querySelector<HTMLElement>('.VPLocalNav .menu')?.click()
return false
}
const { right } = sidebar.getBoundingClientRect()
return Math.abs(right - document.documentElement.clientWidth) < 1
})
const open = await box('.VPSidebar')
expect(open.x).toBeLessThan(viewport)
})
})

@ -0,0 +1,84 @@
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// the default theme is laid out with logical properties so that `dir: 'rtl'`
// mirrors it without a build step; this keeps physical inline-axis
// declarations from creeping back in
const root = fileURLToPath(
new URL('../../../../src/client/theme-default/', import.meta.url)
)
// declarations that must stay physical: centering paired with
// translate(-50%), which anchoring the inline-start edge would push
// off-centre in rtl
const physicalByDesign: Record<string, string[]> = {
'components/VPHero.vue': ['left: 50%', 'left: 50%']
}
// a declaration starts a block, follows another declaration, or sits on its
// own line, so one-line rules are scanned like any other
const physicalInlineAxis =
/(?<=^|[{;])\s*(?:(?:margin|padding|border|inset|scroll-margin|scroll-padding)-(?:left|right)(?:-[a-z]+)?|left|right|border-(?:top|bottom)-(?:left|right)-radius)\s*:[^;}]*|(?<=^|[{;])\s*(?:text-align|float|clear)\s*:\s*(?:left|right)\b|(?<=^|[{;])\s*(?:background-position(?:-x)?|(?:-webkit-)?mask-position|object-position|transform-origin|perspective-origin)\s*:[^;}]*\b(?:left|right)\b/gm
// shorthands set the two inline sides independently; they are physical
// whenever those values differ (values with function calls are skipped)
const shorthand =
/(?<=^|[{;])\s*(?:(margin|padding|inset)[ \t]*:(?![^;}]*\()[ \t]*([^\s;{}!]+)[ \t]+([^\s;{}!]+)[ \t]+([^\s;{}!]+)[ \t]+([^\s;{}!]+)|(border-radius)[ \t]*:(?![^;}]*\()[ \t]*([^\s;{}!]+)[ \t]+([^\s;{}!]+)(?:[ \t]+([^\s;{}!]+))?(?:[ \t]+([^\s;{}!]+))?)[ \t]*(?:!important)?[ \t]*(?=[;}])/gm
function styles(file: string): string {
const source = readFileSync(root + file, 'utf8')
if (file.endsWith('.css')) return source
return Array.from(
source.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g),
(m) => m[1]
).join('\n')
}
function physicalDeclarations(css: string): string[] {
const found = Array.from(css.matchAll(physicalInlineAxis), (m) => m[0].trim())
for (const m of css.matchAll(shorthand)) {
const decl = m[0].trim()
if (m[1]) {
// top right bottom left
if (m[3] !== m[5]) found.push(decl)
} else {
// top-left top-right bottom-right bottom-left, expanded from the
// two- and three-value forms
const [tl, tr, br, bl] =
m[10] !== undefined
? [m[7], m[8], m[9], m[10]]
: m[9] !== undefined
? [m[7], m[8], m[9], m[8]]
: [m[7], m[8], m[7], m[8]]
if (tl !== tr || br !== bl) found.push(decl)
}
}
return found
}
describe('client/theme-default/logical-properties', () => {
const files = readdirSync(root, { recursive: true, encoding: 'utf8' })
.map((file) => file.replaceAll('\\', '/'))
.filter((file) => /\.(vue|css)$/.test(file))
.sort()
test('every allowlisted file still exists', () => {
expect(
Object.keys(physicalByDesign).filter((file) => !files.includes(file))
).toEqual([])
})
test.each(files)('%s uses no physical inline-axis properties', (file) => {
const found = physicalDeclarations(styles(file))
const allowed = [...(physicalByDesign[file] ?? [])]
const unexpected = found.filter((decl) => {
const i = allowed.indexOf(decl)
if (i === -1) return true
allowed.splice(i, 1)
return false
})
expect(unexpected).toEqual([])
expect(allowed).toEqual([])
})
})

@ -1,13 +1,18 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { resolveConfig } from 'node/config'
import { createContentLoader } from 'node/contentLoader'
import { disposeMdItInstance } from 'node/markdown/markdown'
describe('node/contentLoader', () => {
let root: string | undefined
beforeEach(() => {
disposeMdItInstance()
})
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
@ -29,6 +34,18 @@ describe('node/contentLoader', () => {
;(global as any).VITEPRESS_CONFIG = siteConfig
}
test('excludes drafts with a string globOptions.ignore pattern', async () => {
await setup(false)
await mkdir(path.join(root!, 'drafts'))
await writeFile(path.join(root!, 'drafts/post.md'), '# Unpublished')
const data = await createContentLoader('**/*.md', {
globOptions: { cwd: root, ignore: 'drafts/**' }
}).load()
expect(data.map((page) => page.url)).toEqual(['/', '/other.html'])
})
test('rendered internal links get .html when cleanUrls is false', async () => {
await setup(false)
@ -64,4 +81,52 @@ describe('node/contentLoader', () => {
expect(data[0].excerpt).toContain('Intro says My Post.')
})
test.each([false, true])(
'uses the rewritten locale for rendered content and excerpts (render: %s)',
async (render) => {
await setup(false)
await mkdir(path.join(root!, '.vitepress'))
await writeFile(
path.join(root!, '.vitepress/config.mjs'),
`export default {
rewrites: { 'translated.md': 'fr/post.md' },
locales: {
root: { label: 'English', lang: 'en' },
fr: {
label: 'French', lang: 'fr',
markdown: { container: { tipLabel: 'Conseil' } }
}
}
}`
)
const content = '# Post\n\n::: tip\nSome advice.\n:::\n\n---\n\nBody.\n'
await writeFile(path.join(root!, 'translated.md'), content)
await writeFile(path.join(root!, 'original.md'), content)
await mkdir(path.join(root!, 'fr'))
await writeFile(path.join(root!, 'fr/native.md'), content)
;(global as any).VITEPRESS_CONFIG = await resolveConfig(
root!,
'build',
'production'
)
const data = await createContentLoader(
['translated.md', 'original.md', 'fr/native.md'],
{ render, excerpt: true }
).load()
const translated = data.find((page) => page.url === '/fr/post.html')!
const native = data.find((page) => page.url === '/fr/native.html')!
const original = data.find((page) => page.url === '/original.html')!
expect(translated.excerpt).toContain('Conseil')
expect(native.excerpt).toContain('Conseil')
expect(original.excerpt).toContain('TIP')
if (render) {
expect(translated.html).toContain('Conseil')
expect(native.html).toContain('Conseil')
expect(original.html).toContain('TIP')
}
}
)
})

@ -73,11 +73,11 @@ describe('node/markdown/markdown', () => {
test('preWrapper', async () => {
const src = '```js\nconst a = 1\n```'
const enabled = await render(src)
expect(enabled).toContain('<div class="language-js">')
expect(enabled).toContain('<div class="language-js" dir="ltr">')
expect(enabled).toContain('class="copy"')
const disabled = await render(src, { preWrapper: false })
expect(disabled).not.toContain('<div class="language-js">')
expect(disabled).not.toContain('<div class="language-js" dir="ltr">')
expect(disabled).not.toContain('class="copy"')
})

@ -91,7 +91,7 @@ describe('node/markdown/plugins/containers', () => {
<p>content</p>
</div>
<details class="details custom-block"><summary>Click me to toggle the code</summary>
<div class="language-js"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">console.log('hi')
<div class="language-js" dir="ltr"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">console.log('hi')
</code></pre>
</div></details>
"
@ -288,9 +288,9 @@ describe('node/markdown/plugins/containers', () => {
].join('\n')
expect(await render(src)).toMatchInlineSnapshot(`
"<div class="vp-code-group"><div class="tabs"><input type="radio" name="group-0" id="tab-1" checked><label data-title="config.js" for="tab-1">config.js</label><input type="radio" name="group-0" id="tab-2" ><label data-title="config.ts" for="tab-2">config.ts</label></div><div class="blocks">
<div class="language-js active"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
<div class="language-js active" dir="ltr"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
</code></pre>
</div><div class="language-ts"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">ts</span><pre><code class="language-ts">const a: number = 1
</div><div class="language-ts" dir="ltr"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">ts</span><pre><code class="language-ts">const a: number = 1
</code></pre>
</div></div></div>
"
@ -450,7 +450,7 @@ describe('node/markdown/plugins/containers (github alerts)', () => {
<ul>
<li>list item</li>
</ul>
<div class="language-js"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
<div class="language-js" dir="ltr"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
</code></pre>
</div></div>
"

@ -0,0 +1,73 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
createMarkdownRenderer,
disposeMdItInstance,
type MarkdownRenderer
} from 'node/markdown/markdown'
describe('image dimensions with URL suffixes', () => {
let root: string
let md: MarkdownRenderer
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-image-url-'))
await mkdir(path.join(root, 'public'))
for (const file of [
'diagram.svg',
'diagram space.svg',
'diagram#hash.svg',
'public/diagram.svg'
]) {
await writeFile(
path.join(root, file),
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><g id="layer"/></svg>'
)
}
disposeMdItInstance()
md = await createMarkdownRenderer(root, { highlight: (code) => code })
})
afterAll(async () => {
disposeMdItInstance()
await rm(root, { recursive: true, force: true })
})
describe.each([
'./diagram.svg',
'/diagram.svg',
'./diagram%20space.svg',
'./diagram%23hash.svg'
])('%s', (pathname) => {
test.each(['?v=1', '#layer', '?v=1#layer'])(
'reads dimensions while preserving %s',
async (suffix) => {
const src = pathname + suffix
const html = await md.renderAsync(`![Diagram](${src})`, {
path: path.join(root, 'index.md')
})
expect(html).toContain(`src="${decodeURIComponent(src)}"`)
expect(html).toContain('width="120"')
expect(html).toContain('height="80"')
}
)
})
test.each([
['width=240', '240', '160'],
['height=40', '60', '40'],
['width=240 height=90', '240', '90']
])('respects explicit dimensions: %s', async (attrs, width, height) => {
const html = await md.renderAsync(
`![Diagram](/diagram.svg?v=1#layer){${attrs}}`,
{ path: path.join(root, 'index.md') }
)
expect(html).toContain('src="/diagram.svg?v=1#layer"')
expect(html).toContain(`width="${width}"`)
expect(html).toContain(`height="${height}"`)
})
})

@ -61,6 +61,42 @@ describe('node/markdown/plugins/link', () => {
expect(env.links).toEqual(['./missing'])
expect(env.linkLines).toEqual([3])
})
test.each([false, true])(
'preserves index page queries (cleanUrls: %s)',
async (cleanUrls) => {
for (const [source, expected] of [
['/guide/index.md?lang=fr', '/guide/?lang=fr'],
[
'./index.md?lang=fr&mode=full#Hello%20World',
'./?lang=fr&amp;mode=full#hello-world'
],
[
'/guide/index.md?next=/other/index.md#Hello%20World',
'/guide/?next=/other/index.md#hello-world'
],
[
'/guide/index.md?lang=fr#:~:text=Hello%20World',
'/guide/?lang=fr#:~:text=Hello%20World'
]
]) {
expect(
await md.renderAsync(`[link](${source})`, { cleanUrls })
).toContain(`href="${expected}"`)
}
}
)
test.each([false, true])(
'only removes the exact index.md filename (cleanUrls: %s)',
async (cleanUrls) => {
for (const file of ['indexAmd', 'index.md-extra']) {
expect(
await md.renderAsync(`[link](/guide/${file})`, { cleanUrls })
).toContain(`href="/guide/${file}${cleanUrls ? '' : '.html'}"`)
}
}
)
})
describe('node/markdown/plugins/link with a relative base', () => {
@ -114,6 +150,19 @@ describe('node/markdown/plugins/link with a relative base', () => {
)
})
test.each([false, true])(
'preserves index page queries with a relative base (cleanUrls: %s)',
async (cleanUrls) => {
expect(
await render('[link](/guide/index.md?lang=fr#Hello%20World)', {
cleanUrls
})
).toContain(
`href="../guide/${cleanUrls ? '' : 'index.html'}?lang=fr#hello-world"`
)
}
)
test('content-loader renders keep absolute links site-absolute', async () => {
// content loaders set relativePath but not relativizeUrls — their html
// is embedded in other pages, so the source's depth must not apply

@ -0,0 +1,73 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { resolveConfig } from 'node/config'
import { disposeMdItInstance } from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { compileScript, parse } from 'vue/compiler-sfc'
describe('page data script serialization', () => {
let root: string
let file: string
let render: Awaited<ReturnType<typeof createMarkdownToVueRenderFn>>
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-data-'))
file = path.join(root, 'index.md')
await writeFile(file, '# Page\n')
const siteConfig = await resolveConfig(root, 'build', 'production')
disposeMdItInstance()
render = await createMarkdownToVueRenderFn(
root,
{ cache: false },
'/',
false,
false,
siteConfig
)
})
afterAll(async () => {
disposeMdItInstance()
await rm(root, { recursive: true, force: true })
})
describe.each([
['no script', ''],
['named export', '<script>\nexport const custom = true\n</script>'],
[
'default export',
'<script>\nexport default { name: "Custom" }\n</script>'
],
['script setup', '<script setup>\nconst custom = true\n</script>']
])('%s', (_, script) => {
test.each([
'Document </script> tags literally',
"Regular-expression substitutions: $&, $`, $' and $$"
])('preserves %j', async (description) => {
const src = [
'---',
`description: ${JSON.stringify(description)}`,
'---',
'',
'# Page',
'',
script
].join('\n')
const result = await render(src, file)
const { descriptor, errors } = parse(result.vueSrc, { filename: file })
expect(errors).toEqual([])
expect(() => compileScript(descriptor, { id: 'page-data' })).not.toThrow()
const module = await import(
'data:text/javascript;base64,' +
Buffer.from(descriptor.script!.content).toString('base64')
)
expect(module.__pageData).toEqual(result.pageData)
expect(module.__pageData.description).toBe(description)
expect(module.__pageData.frontmatter.description).toBe(description)
})
})
})

@ -0,0 +1,38 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { glob } from 'node/utils/glob'
describe('node/utils/glob', () => {
let root: string
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-glob-'))
for (const dir of ['drafts', 'dist', 'node_modules']) {
await mkdir(path.join(root, dir))
await writeFile(path.join(root, dir, 'post.md'), '# Post')
}
await writeFile(path.join(root, 'published.md'), '# Published')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
test.each([{ ignore: 'drafts/**' }, { ignore: ['drafts/**'] }])(
'accepts an ignore pattern as $ignore',
async ({ ignore }) => {
expect(await glob(['**/*.md'], { cwd: root, ignore })).toEqual([
'published.md'
])
}
)
test('keeps the default exclusions without a custom ignore pattern', async () => {
expect(await glob(['**/*.md'], { cwd: root })).toEqual([
'drafts/post.md',
'published.md'
])
})
})

@ -2,11 +2,29 @@ import {
isRelativeBase,
joinPath,
mergeHead,
pageChunkPath,
relativePathToRoot,
type HeadConfig
} from 'shared/shared'
describe('shared/shared', () => {
describe('pageChunkPath', () => {
test('keeps flat chunks directly in assetsDir', () => {
expect(pageChunkPath('guide_foo.md', 'Ab-12xyz')).toBe(
'guide_foo.md.Ab-12xyz.js'
)
})
test('prefixes the shard recorded in the hash map entry', () => {
expect(pageChunkPath('guide_foo.md', '3/Ab-12xyz')).toBe(
'3/guide_foo.md.Ab-12xyz.js'
)
expect(pageChunkPath('guide_foo.md', '3/Ab-12xyz', '.lean.js')).toBe(
'3/guide_foo.md.Ab-12xyz.lean.js'
)
})
})
describe('mergeHead', () => {
test('replaces meta tags with the same key in place', () => {
expect(

@ -1,8 +0,0 @@
{
"plugins": {
"postcss-rtlcss": {
"ltrPrefix": ":where([dir=\"ltr\"])",
"rtlPrefix": ":where([dir=\"rtl\"])"
}
}
}

@ -26,10 +26,7 @@ const showModal = ref(false)
.modal-mask {
position: fixed;
z-index: 200;
top: 0;
left: 0;
width: 100%;
height: 100%;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;

@ -1,5 +1,5 @@
import { defineAdditionalConfig, type DefaultTheme } from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export default defineAdditionalConfig({
description: 'Vite & Vue powered static site generator.',
@ -37,7 +37,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -316,6 +316,20 @@ You can deploy your VitePress project with [CloudRay](https://cloudray.io/) by f
You can deploy your VitePress project with [Hostinger](https://www.hostinger.com/web-apps-hosting) by following these [instructions](https://www.hostinger.com/support/how-to-deploy-a-nodejs-website-in-hostinger/). While configuring build settings, choose VitePress as the framework and adjust the root directory to `./docs`.
### Lizard
[Lizard (lizard.build)](https://lizard.build) builds VitePress sites from source and serves the generated HTML. For the layout used in this guide, it detects `docs:build` and serves `docs/.vitepress/dist` on port `80`.
Install the [Lizard CLI](https://lizard.build/docs/cli) and sign in with `lizard login`. To deploy a local source directory, run these commands from the project root containing `package.json`:
```sh
lizard init --name vitepress-docs
lizard add --service web
lizard up --service web --port 80
```
Leave build and start command overrides unset to use automatic detection. For GitHub deployments or other layouts, see the [Lizard VitePress guide](https://lizard.build/docs/framework-guides/vitepress).
### Stormkit
You can deploy your VitePress project to [Stormkit](https://www.stormkit.io) by following these [instructions](https://stormkit.io/blog/how-to-deploy-vitepress).

@ -44,7 +44,7 @@ The following properties can be overridden for each locale (including root):
```ts
interface LocaleSpecificConfig<ThemeConfig = any> {
lang?: string
dir?: string | false
dir?: 'ltr' | 'rtl' | 'auto' | false
title?: string
titleTemplate?: string | boolean
description?: string
@ -143,6 +143,23 @@ watchEffect(() => {
</template>
```
## RTL Support (Experimental)
## RTL Support
For RTL support, specify `dir: 'rtl'` in config and use some RTLCSS PostCSS plugin like <https://github.com/MohammadYounes/rtlcss>, <https://github.com/vkalinichev/postcss-rtl> or <https://github.com/elchininet/postcss-rtlcss>. You'll need to configure your PostCSS plugin to use `:where([dir="ltr"])` and `:where([dir="rtl"])` as prefixes to prevent CSS specificity issues. If users can switch direction at runtime, set `dir: false` and update `document.documentElement.dir` in your application code.
For right-to-left languages, set `dir: 'rtl'` in the config. The default theme is laid out with [CSS logical properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values), so the layout, the navigation and directional icons follow the document direction on their own. No PostCSS plugin is needed; an RTLCSS plugin left in place would flip the mirrored styles a second time, so remove it.
```ts [docs/.vitepress/config.ts]
export default {
lang: 'fa-IR',
dir: 'rtl'
}
```
For a multilingual site, set `dir` per locale in `locales`. It can also be overridden for a single page with the [`dir`](../reference/frontmatter-config#dir) frontmatter option. Code blocks always stay left-to-right.
When adding your own styles, prefer logical properties such as `margin-inline-start` over `margin-left`, and mirror your own directional icons in right-to-left layouts:
```css
[dir='rtl'] .my-arrow-icon {
scale: -1 1;
}
```

@ -208,7 +208,8 @@ export type SidebarItem = {
Setting this value to `false` prevents rendering of aside container.\
Setting this value to `true` renders the aside to the right.\
Setting this value to `left` renders the aside to the left.
Setting this value to `left` renders the aside to the left.\
In right-to-left layouts, both sides are mirrored.
If you want to disable it for all viewports, you should use `outline: false` instead.

@ -83,6 +83,18 @@ type HeadConfig =
| [string, Record<string, string>, string]
```
## dir
- Type: `'ltr' | 'rtl' | 'auto'`
Overrides the [text direction](./site-config#dir) of the site for the current page.
```yaml
---
dir: rtl
---
```
## Default Theme Only
The following frontmatter options are only applicable when using the default theme.

@ -40,7 +40,7 @@ interface VitePressData<T = any> {
description: Ref<string>
lang: Ref<string>
isDark: Ref<boolean>
dir: Ref<string | false>
dir: Ref<'ltr' | 'rtl' | 'auto' | false>
localeIndex: Ref<string>
/**
* Current location hash

@ -367,6 +367,20 @@ export default {
}
```
### dir
- Type: `'ltr' | 'rtl' | 'auto'`
- Default: `ltr`
- Can be overridden at the [directory level](#directory-level-overrides)
The text direction of the site. This will render as a `<html dir="rtl">` tag in the page HTML, and the default theme mirrors its layout for right-to-left languages. It can also be overridden per page via [frontmatter](./frontmatter-config#dir). Set it to `false` to let application code manage the attribute. See [RTL Support](../guide/i18n#rtl-support).
```ts
export default {
dir: 'rtl'
}
```
### base
- Type: `string`
@ -489,6 +503,21 @@ When `assetsBase` points at another origin, VitePress adds `crossorigin` to the
Only production builds are affected. `vitepress preview` serves a root-absolute `assetsBase` (like `/cdn/`) from the local dist; an external one is requested from the real URL. Can also be set per build with `vitepress build --assetsBase https://cdn.example.com/`.
### assetsShards
- Type: `number`
- Default: `undefined`
Spreads the generated assets over this many subdirectories of [`assetsDir`](#assetsdir), `assets/0/` through `assets/N-1/`, instead of one flat directory. Use it when the host caps the number of files per directory; Netlify, for example, allows 54,000. Each page emits two JavaScript files, so a site with 60,000 pages needs at least three shards, plus some headroom because files are distributed by a hash of their name.
```ts
export default {
assetsShards: 4
}
```
Shared chunks stay in `assets/chunks/`. A file's shard depends only on its name, so unchanged files keep their URL between builds. Only production builds are affected.
### icons
- Type: `{ include?: string[] }`

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -89,7 +89,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/es/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -97,7 +97,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -60,7 +60,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -89,7 +89,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/ko/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -13,9 +13,8 @@
"@lunariajs/core": "^0.1.1",
"markdown-it-mathjax3": "^4.3.2",
"open-cli": "^9.0.0",
"postcss-rtlcss": "^6.0.0",
"vitepress": "workspace:*",
"vitepress-plugin-group-icons": "^1.7.6",
"vitepress-plugin-llms": "^1.13.4"
"vitepress-plugin-llms": "^1.13.5"
}
}

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -89,7 +89,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/pt/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -28,10 +28,7 @@ const showModal = ref(false)
.modal-mask {
position: fixed;
z-index: 200;
top: 0;
left: 0;
width: 100%;
height: 100%;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -87,7 +87,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/ru/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -36,23 +36,15 @@ PDF-файлы или другие документы, на которые ес
## Базовый URL {#base-url}
Если ваш сайт развёрнут на URL-адресе, не являющемся корневым, вам нужно установить параметр `base` в файле `.vitepress/config.js`. Например, если вы планируете развернуть свой сайт на `https://foo.github.io/bar/`, то параметр `base` следует установить на `'/bar/'` (он всегда должен начинаться и заканчиваться слэшем).
Если ваш сайт развёрнут не в корне URL, задайте параметр [`base`](../reference/site-config#base). Например, если вы планируете разместить сайт по адресу `https://foo.github.io/bar/`, то `base` должен быть установлен в `'/bar/'`.
Все пути к статическим ресурсам автоматически обрабатываются с учётом различных значений конфигурации `base`. Например, если в вашей разметке есть абсолютная ссылка на ресурс в директории `public`:
Ссылки на статические ресурсы автоматически корректируются под `base`, поэтому абсолютная ссылка на файл в `public` работает с любым `base` и никогда не требует обновления:
```md
![Изображение](/image-inside-public.png)
```
В этом случае вам **не** нужно обновлять его при изменении значения конфигурации `base`.
Однако если вы создаете компонент темы, который динамически ссылается на активы, например, изображение, атрибут `src` которого основан на значении конфигурации темы:
```vue
<img :src="theme.logoPath" />
```
В этом случае рекомендуется обернуть путь с помощью [хелпера `withBase`](../reference/runtime-api#withbase), предоставляемого VitePress:
Внимания требуют только динамически формируемые пути — например, изображение, `src` которого основан на значении конфигурации темы. Оборачивайте такие пути хелпером [`withBase`](../reference/runtime-api#withbase), чтобы `base` подставлялся во время выполнения:
```vue
<script setup>
@ -65,3 +57,26 @@ const { theme } = useData()
<img :src="withBase(theme.logoPath)" />
</template>
```
## Раздача ресурсов через CDN {#serving-assets-from-a-cdn}
Чтобы раздавать сгенерированные ресурсы — скрипты, стили, шрифты и изображения, импортированные из Markdown или компонентов — с другого домена, отличного от страниц, задайте [`assetsBase`](../reference/site-config#assetsbase):
```ts
export default {
base: '/',
assetsBase: 'https://cdn.example.com/'
}
```
Загрузите директорию `assets` из результата сборки на CDN так, чтобы она была доступна по адресу `https://cdn.example.com/assets/`, а остальную часть результата разверните на своём сайте как обычно. Файлы в `public` ссылаются относительно `base` и остаются вместе со страницами.
Поскольку это значение часто зависит от окружения, его также можно передать через командную строку:
```sh
vitepress build docs --assetsBase "$CDN_URL"
```
::: warning Требуется CORS
Модульные скрипты всегда загружаются в режиме CORS, поэтому кросс-доменный CDN должен отвечать соответствующим заголовком `Access-Control-Allow-Origin`.
:::

@ -33,12 +33,17 @@ interface Theme {
*/
Layout: Component
/**
* Улучшение экземпляра приложения Vue
* Расширяем экземпляр приложения Vue
* @optional
*/
enhanceApp?: (ctx: EnhanceAppContext) => Awaitable<void>
/**
* Расширяем другую тему, вызывая её `enhanceApp` перед нашей
* Выполняется внутри `setup()` корневого компонента
* @optional
*/
setup?: () => void
/**
* Расширяем другую тему, вызывая её `enhanceApp` и `setup` перед нашими
* @optional
*/
extends?: Theme
@ -88,6 +93,26 @@ export default {
Верните `false` из `onBeforeRouteChange` или `onBeforePageLoad`, чтобы отменить переход.
Хук `setup` выполняется внутри `setup()` корневого компонента, поэтому вызовы Composition API (`onMounted`, `watch`, composables и т. д.) работают там без необходимости оборачивать компонент layout:
```ts [.vitepress/theme/index.ts]
import { watch } from 'vue'
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
export default {
extends: DefaultTheme,
setup() {
const { page } = useData()
watch(() => page.value.relativePath, (path) => {
console.log('now viewing', path)
})
}
}
```
При использовании `extends` `setup` каждой темы выполняется в порядке от базовой к производной, как и `enhanceApp`. Он также выполняется во время SSR/SSG-рендеринга, поэтому код, зависящий от браузера, следует размещать внутри `onMounted`.
Экспорт по умолчанию является единственным контрактом для пользовательской темы, и только свойство `Layout` является обязательным. Таким образом, технически тема VitePress может быть простой, как один компонент Vue.
Внутри компонент макета работает так же, как и обычное приложение Vite + Vue 3. Обратите внимание, что тема также должна быть [SSR-совместимой](./ssr-compat).

@ -54,6 +54,30 @@ outline: deep
**Пример:** Если вы используете Github (или GitLab) Pages и развёртываете на `user.github.io/repo/`, то установите `base` на `/repo/`.
## Переносимые сборки (относительный base) {#relocatable-builds-relative-base}
Когда конечный URL сайта неизвестен на момент сборки — шлюз IPFS (`https://gateway/ipfs/<cid>/…`), Wayback Machine, общая папка, документация, встроенная в приложение — установите `base` равным `'./'`:
```ts
export default {
base: './'
}
```
Каждая страница затем ссылается на ресурсы и другие страницы относительно своего собственного расположения, а клиентский рантайм восстанавливает реальную точку монтирования при загрузке страницы. Одна и та же сборка работает из **любого** подпути без пересборки — в том числе из нескольких одновременно — с полностью работающими маршрутизацией, поиском и предзагрузкой.
Открытие сгенерированных HTML-файлов напрямую из файловой системы (`file://`) также работает как стилизованный, полностью навигируемый статический сайт. Браузеры блокируют JavaScript-модули при использовании `file://`, поэтому гидратации там нет — интерактивные функции вроде поиска остаются неактивными, при этом весь предварительно отрендеренный контент и ссылки продолжают работать.
Несколько важных моментов:
- Держите [`cleanUrls`](../reference/site-config#cleanurls) выключенным (значение по умолчанию): переносимому выводу нужны ссылки, оканчивающиеся на `.html`, поскольку нет сервера для переписывания «красивых» URL.
- `404.html` генерируется для корневой глубины. Хосты, отдающие его как fallback для URL произвольной глубины, отрендерят его без стилей (для неизвестной глубины нет корректного относительного префикса).
- Записи [`head`](../reference/site-config#head) выводятся как есть, как и всегда — избегайте в них корне-абсолютных путей вроде `/favicon.ico` и предпочитайте абсолютные URL или `transformHead`.
- Сырые HTML-теги `<a>` в Markdown сохраняют `href` в том виде, как написаны — используйте синтаксис Markdown-ссылок для сайт-абсолютных ссылок (встроенные источники `<img>` проходят через пайплайн ресурсов и обрабатываются корректно).
- Ссылки, созданные [`createContentLoader`](./data-loading#createcontentloader), остаются сайт-абсолютными (их HTML встраивается в другие страницы, поэтому единого корректного относительного префикса не существует) — они разрешаются только для корневого монтирования.
- Отдавайте страницы по их каноническим URL: корень как `/dir/` (не `/dir`), и без добавленных завершающих слэшей у URL страниц. Относительный префикс разрешается относительно URL, который браузер реально показывает, а практически все статические хостинги уже канонизируют именно так.
- Dev-сервер всегда отдаёт по `/`; относительное поведение применяется к продакшен-сборке.
## Заголовки кэша HTTP {#http-cache-headers}
Если вы контролируете HTTP-заголовки на своем рабочем сервере, можно настроить заголовки `cache-control` для достижения лучшей производительности при повторных посещениях.
@ -229,6 +253,8 @@ Cache-Control: max-age=31536000,immutable
- main
```
<!-- keep headings sorted alphabetically, leave nginx at the end -->
### Azure
1. Следуйте [официальной документации](https://docs.microsoft.com/ru-ru/azure/static-web-apps/build-configuration).
@ -290,62 +316,77 @@ Cache-Control: max-age=31536000,immutable
Вы можете развернуть свой проект VitePress на [Hostinger](https://www.hostinger.com/web-apps-hosting), следуя этим [инструкциям](https://www.hostinger.com/support/how-to-deploy-a-nodejs-website-in-hostinger/). При настройке параметров сборки выберите VitePress в качестве фреймворка и укажите корневой каталог `./docs`.
### Kinsta
Вы можете развернуть свой сайт VitePress на [Kinsta](https://kinsta.com/static-site-hosting/), следуя этим [инструкциям](https://kinsta.com/docs/vitepress-static-site-example/).
### Stormkit
Вы можете развернуть свой проект VitePress на [Stormkit](https://www.stormkit.io), следуя следующим [инструкциям](https://stormkit.io/blog/how-to-deploy-vitepress).
### Surge
1. После запуска `npm run docs:build` выполните эту команду для развёртывания:
После запуска `npm run docs:build` выполните эту команду для развёртывания на [Surge](https://surge.sh):
```sh
npx surge docs/.vitepress/dist
```
```sh
npx surge docs/.vitepress/dist
```
### harvis
После выполнения `npm run docs:build` выполните эту команду для развёртывания на [harvis](https://harvis.dev):
```sh
npx harvis docs/.vitepress/dist
```
### Nginx
Вот пример конфигурации блока сервера Nginx. Эта настройка включает сжатие gzip для общих текстовых ресурсов, правила обслуживания статических файлов вашего сайта VitePress с правильными заголовками кэширования и обработку параметра `cleanUrls: true`.
```nginx
server {
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
map $uri $cache_control {
~^/assets/ "public, max-age=31536000, immutable";
default "no-cache";
}
listen 80;
server {
listen 8080;
listen [::]:8080;
server_name _;
index index.html;
location / {
# расположение контента
root /app;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
server_tokens off;
# точные совпадения -> обратные чистые URL-адреса -> папки -> не найдены
try_files $uri $uri.html $uri/ =404;
absolute_redirect off;
# несуществующие страницы
error_page 404 /404.html;
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
application/manifest+json
image/svg+xml
text/css
text/javascript
text/plain;
add_header Cache-Control $cache_control always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# папка без index.html вызывает ошибку 403 в этой настройке
error_page 403 /404.html;
location / {
try_files $uri $uri.html $uri/index.html =404;
}
# настройка заголовков кэширования
# файлы в папке с ресурсами имеют хэши имён файлов
location ~* ^/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
location ~ ^(?<page>.+)/$ {
if (-f $document_root$page.html) {
return 301 $page$is_args$args;
}
try_files $page/index.html =404;
}
error_page 404 /404.html;
}
```
Эта конфигурация предполагает, что ваш собранный сайт VitePress находится в директории `/app`. При необходимости измените директиву `root`, если файлы вашего сайта расположены в другом месте.
::: warning Не используйте index.html по умолчанию
Разрешение try_files не должно использовать index.html, как это делается в других приложениях Vue. Это может привести к недопустимому состоянию страницы.
:::
Дополнительную информацию можно найти в официальной документации [Nginx](https://nginx.org/ru/docs/), а также в следующих обсуждениях: [#2837](https://github.com/vuejs/vitepress/discussions/2837), [#3235](https://github.com/vuejs/vitepress/issues/3235), а также в [блоге Mehdi Merah](https://blog.mehdi.cc/articles/vitepress-cleanurls-on-nginx-environment#readings).

@ -40,6 +40,46 @@ export default DefaultTheme
См. [переменные CSS темы по умолчанию](https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css), которые можно переопределить.
### Навбар {#navbar}
Навбар отрисовывает единую поверхность фона, управляемую CSS-переменными, поэтому его вид можно изменить, не затрагивая внутреннюю реализацию компонента:
```css
:root {
/* высота бара и фон */
--vp-nav-height: 4rem;
--vp-nav-bg-color: var(--vp-c-bg);
/* фон, когда находимся поверх главной страницы (без прокрутки);
установите var(--vp-nav-bg-color), чтобы отказаться от прозрачного оформления */
--vp-nav-home-bg-color: transparent;
/* фильтр, применяемый к контенту за баром */
--vp-nav-backdrop-filter: none;
/* нижняя линия бара и фон мобильного меню */
--vp-nav-divider-color: var(--vp-c-gutter);
--vp-nav-screen-bg-color: var(--vp-c-bg);
}
```
Например, навбар в стиле матового стекла:
```css
:root {
--vp-nav-bg-color: color-mix(in srgb, var(--vp-c-bg) 65%, transparent);
--vp-nav-backdrop-filter: saturate(180%) blur(8px);
}
```
Та же обработка распространяется и на локальную навигацию: `--vp-local-nav-bg-color` по умолчанию следует за цветом поверхности навбара, и там, где два бара соприкасаются, они разделяют единую размытую поверхность, так что стекло остаётся непрерывным между ними.
::: warning
`backdrop-filter` заметно влияет на производительность прокрутки, особенно на больших или High-DPI экранах. При использовании полупрозрачного бара также проверьте контрастность текста поверх содержимого страницы. Safari 17 и более ранние версии не применяют управляемые переменными backdrop-фильтры, поэтому показывают полупрозрачный цвет без размытия.
:::
Когда элементы навигации не помещаются в доступную ширину, они перемещаются в меню `⋯` в конце навбара вместо того, чтобы обрезаться, начиная с ссылок на соцсети, переключателя внешнего вида и переключателя локали, за которыми следуют элементы навигации справа налево. Подпись этой кнопки можно локализовать с помощью [`extraMenuLabel`](../reference/default-theme-config#extramenulabel).
## Использование различных шрифтов {#using-different-fonts}
VitePress использует [Inter](https://rsms.me/inter/) в качестве шрифта по умолчанию, и будет включать шрифты в вывод сборки. Шрифт также автоматически загружается в производство. Однако это может быть нежелательно, если вы хотите использовать другой основной шрифт.

@ -36,6 +36,8 @@ editLink: true
Содержание руководства
```
Обращения к свойствам вроде `{{ $frontmatter.title }}` разрешаются в процессе рендеринга Markdown, поэтому значение также попадает в локальный индекс поиска, в вывод [загрузчика контента](./data-loading#createcontentloader), в якоря заголовков — заголовок выше получает `id="docs-with-vitepress"` — и в цели ссылок, записанные без пробелов вокруг выражения, например `[text]({{$frontmatter.link}})`. Остальные выражения вычисляются Vue во время выполнения как обычно, а обёртывание выражения в [`v-pre`](./using-vue#escaping) выводит его буквально.
Вы также можете получить доступ к метаданным текущей страницы в `<script setup>` с помощью [хелпера `useData()`](../reference/runtime-api#usedata).
## Альтернативные форматы метаданных {#alternative-frontmatter-formats}

@ -449,6 +449,8 @@ VitePress также поддерживает [Оповещения в стил
> [!CAUTION]
> Негативные потенциальные последствия того или иного действия.
По умолчанию цвета оповещений совпадают с цветами GitHub, при этом caution и danger отображаются красным. Включите [`themeConfig.gradedContainers`](../reference/default-theme-config#gradedcontainers), чтобы использовать градуированную шкалу серьёзности: danger (красный), warning (оранжевый) и caution (жёлтый). Обратите внимание, что `[!DANGER]` — это расширение VitePress и на GitHub будет отображаться как обычная цитата.
## Подсветка синтаксиса в блоках кода {#syntax-highlighting-in-code-blocks}
VitePress использует [Shiki](https://github.com/shikijs/shiki) для выделения синтаксиса языка в блоках кода Markdown с помощью цветного текста. Shiki поддерживает широкий спектр языков программирования. Всё, что вам нужно сделать, это добавить правильный псевдоним языка к начальным обратным кавычкам блока кода:

@ -45,6 +45,7 @@ vitepress build [root]
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `--mpa` (экспериментально) | Сборка в режиме [MPA](../guide/mpa-mode) без гидратации на стороне клиента (`boolean`) |
| `--base <path>` | Публичный базовый путь (по умолчанию: `/`) (`string`) |
| `--assetsBase <url>` | Префикс URL, с которого раздаются сгенерированные ресурсы, например CDN (`string`) |
| `--target <target>` | Транспилировать цель (по умолчанию: `"modules"`) (`string`) |
| `--outDir <dir>` | Выходной каталог относительно **cwd** (по умолчанию: `<root>/.vitepress/dist`) (`string`) |
| `--assetsInlineLimit <number>` | Статический встроенный порог ресурса base64 в байтах (по умолчанию: `4096`) (`number`) |
@ -64,6 +65,7 @@ vitepress preview [root]
| Параметр | Описание |
| --------------- | ----------------------------------------------------- |
| `--base <path>` | Публичный базовый путь (по умолчанию: `/`) (`string`) |
| `--assetsBase <url>` | Префикс URL, с которого раздаются сгенерированные ресурсы, например CDN (`string`) |
| `--port <port>` | Номер порта (`number`) |
## `vitepress init` {#vitepress-init}

@ -11,21 +11,18 @@ description: Используйте компонент Badge для добавл
Вы можете использовать компонент `Badge`, который доступен глобально.
```html
### Заголовок <Badge type="info" text="по умолчанию" /> ### Заголовок
<Badge type="tip" text="^1.9.0" /> ### Заголовок
<Badge type="warning" text="beta" /> ### Заголовок
<Badge type="danger" text="осторожно" />
### Заголовок <Badge type="info" text="по умолчанию" />
### Заголовок <Badge type="tip" text="^1.9.0" />
### Заголовок <Badge type="warning" text="beta" />
### Заголовок <Badge type="danger" text="устарело" />
```
Приведённый выше код даёт такой результат:
### Заголовок <Badge type="info" text="по умолчанию" /> {#title}
### Заголовок <Badge type="tip" text="^1.9.0" /> {#title-1}
### Заголовок <Badge type="warning" text="beta" /> {#title-2}
### Заголовок <Badge type="danger" text="осторожно" /> {#title-3}
### Заголовок <Badge type="danger" text="устарело" /> {#title-3}
## Дочерние элементы {#custom-children}
@ -47,9 +44,21 @@ description: Используйте компонент Badge для добавл
--vp-badge-info-text: var(--vp-c-text-2);
--vp-badge-info-bg: var(--vp-c-default-soft);
--vp-badge-note-border: transparent;
--vp-badge-note-text: var(--vp-c-note-1);
--vp-badge-note-bg: var(--vp-c-note-soft);
--vp-badge-tip-border: transparent;
--vp-badge-tip-text: var(--vp-c-brand-1);
--vp-badge-tip-bg: var(--vp-c-brand-soft);
--vp-badge-tip-text: var(--vp-c-tip-1);
--vp-badge-tip-bg: var(--vp-c-tip-soft);
--vp-badge-important-border: transparent;
--vp-badge-important-text: var(--vp-c-important-1);
--vp-badge-important-bg: var(--vp-c-important-soft);
--vp-badge-caution-border: transparent;
--vp-badge-caution-text: var(--vp-c-caution-1);
--vp-badge-caution-bg: var(--vp-c-caution-soft);
--vp-badge-warning-border: transparent;
--vp-badge-warning-text: var(--vp-c-warning-1);
@ -70,7 +79,7 @@ interface Props {
// Когда передается `<slot>`, это значение игнорируется.
text?: string
// По умолчанию: `tip`.
type?: 'info' | 'tip' | 'warning' | 'danger'
// По умолчанию `tip`. Соответствует цветам контейнеров/оповещений Markdown.
type?: 'info' | 'note' | 'tip' | 'important' | 'caution' | 'warning' | 'danger'
}
```

@ -254,6 +254,9 @@ export default {
{ icon: 'github', link: 'https://github.com/vuejs/vitepress' },
{ icon: 'twitter', link: '...' },
{ icon: 'discord', link: '/community', target: '_self' },
// Вы можете использовать любую другую установленную в проекте коллекцию iconify
// в формате `collection:name` (например, после `npm add -D @iconify-json/lucide`):
{ icon: 'lucide:rss', link: '/feed.rss' },
// Можно добавить пользовательские иконки, передав SVG в виде строки:
{
icon: {
@ -472,6 +475,27 @@ export interface DocFooter {
Можно использовать для настройки aria-метки кнопки переключения языка в панели навигации. Применяется только в том случае, если вы используете [i18n](../guide/i18n).
## navMenuLabel
- Тип: `string`
- По умолчанию: `Main Navigation`
Может использоваться для настройки доступной метки основных ориентиров навигации (меню навбара и мобильное меню).
## mobileMenuLabel
- Тип: `string`
- По умолчанию: `Menu`
Может использоваться для настройки `aria-label` кнопки мобильного меню (гамбургер).
## extraMenuLabel
- Тип: `string`
- По умолчанию: `More options`
Может использоваться для настройки `aria-label` кнопки меню `⋯` в навбаре. Это меню собирает элементы навигации и элементы управления, которые не помещаются в бар при текущей ширине экрана.
## skipToContentLabel
- Тип: `string`
@ -486,6 +510,13 @@ export interface DocFooter {
Отображать ли значок внешней ссылки рядом с внешними ссылками в Markdown.
## gradedContainers
- Тип: `boolean`
- По умолчанию: `false`
Определяет, следует ли раскрашивать [пользовательские контейнеры](../guide/markdown#custom-containers), [оповещения в стиле GitHub](../guide/markdown#github-flavored-alerts) и бейджи по градуированной шкале серьёзности — danger красный, warning оранжевый, caution жёлтый. По умолчанию цвета соответствуют оповещениям GitHub, где caution использует красный цвет danger, а warning — жёлтый.
## `useLayout` <Badge type="info" text="composable" />
Возвращает данные, относящиеся к макету. Возвращаемый объект имеет следующий тип:

@ -215,6 +215,7 @@ export default {
Ваш компонент будет отображаться на панели навигации. VitePress предоставляет следующие дополнительные параметры компонента:
- `screenMenu`: необязательное булево значение, указывающее, находится ли компонент внутри мобильного навигационного меню
- `screenMenu`: необязательный булев флаг, указывающий, находится ли компонент внутри мобильного навигационного меню
- `menu`: необязательный булев флаг, указывающий, находится ли компонент внутри выпадающей панели — например, меню `⋯`, в которое сворачиваются элементы навигации, не помещающиеся в бар. В обоих этих контекстах рендерите плоский список вместо всплывающего флайаута, который в итоге оказался бы вложенным внутрь панели
Пример можно посмотреть в тестах e2e [здесь](https://github.com/vuejs/vitepress/tree/main/__tests__/e2e/.vitepress).

@ -27,11 +27,11 @@ export default defineConfig({
В качестве альтернативы можно использовать [Algolia DocSearch](#algolia-search) или некоторые плагины сообщества, например:
- <https://www.npmjs.com/package/vitepress-plugin-search>
- <https://www.npmjs.com/package/vitepress-plugin-pagefind>
- <https://www.npmjs.com/package/@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}
@ -112,6 +112,10 @@ export default defineConfig({
Подробнее в [документации MiniSearch](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html).
::: info Идентификаторы документов
Идентификаторы документов поиска (в том виде, в котором их видят `searchOptions.filter`, `boostDocument` и необработанный индекс) — это сайт-относительные пути вида `/guide/page.html#section`; они не включают [`base`](../reference/site-config#base). Тема разрешает их относительно `base` при отображении результатов.
:::
### Пользовательский рендерер содержимого {#custom-content-renderer}
Вы можете настроить функцию, используемую для отображения содержимого в формате Markdown перед его индексацией:

@ -63,7 +63,7 @@ description: VitePress
- Тип: `HeadConfig[]`
Укажите дополнительные теги, которые будут выводиться для текущей страницы. Они будут добавляться после других тегов внутри блока head, введённых в конфигурации сайта.
Укажите дополнительные теги, которые будут выводиться для текущей страницы. Они [объединяются](./site-config#head) с тегами head, добавленными в конфигурации сайта.
```yaml
---

@ -136,6 +136,36 @@ router.onBeforeRouteChange = (to) => {
В пользовательских темах этот же экземпляр маршрутизатора доступен через [`enhanceApp`](../guide/custom-theme#theme-interface).
## `useIcon` <Badge type="info" text="композабл" /> {#useicon}
- **Тип**: `(icon: MaybeRefOrGetter<string | { svg: string } | undefined>, el?: MaybeRefOrGetter<HTMLElement | null>) => ComputedRef<string | undefined>`
Отрисовывает иконку [iconify](https://iconify.design/) через пайплайн иконок VitePress. Принимает полностью квалифицированное имя `collection:name` (разрешается относительно пакетов `@iconify-json/*` в зависимостях вашего проекта) и возвращает класс, который нужно поставить на элемент — `vpi-<collection>-<name>`.
Во время SSR имя регистрируется в [`SSGContext`](./site-config#postrender) страницы, поэтому сборка добавляет стили иконки в сгенерированную таблицу стилей; в режиме разработки иконки отдаются dev-сервером по требованию из локально установленных коллекций. Ни одна иконка никогда не загружается с внешнего сервиса.
```vue
<script setup>
import { useIcon } from 'vitepress'
import { useTemplateRef } from 'vue'
const el = useTemplateRef('el')
const iconClass = useIcon('lucide:rocket', el)
</script>
<template>
<span ref="el" :class="iconClass" />
</template>
```
Передайте шаблонную ссылку элемента, несущего класс, чтобы dev-режим мог разрешить на нём иконку. Элементу нужны правила `mask`, которые поставляются с темой по умолчанию; в кастомной теме без них dev применяет встроенный эквивалент, а сгенерированная таблица стилей включает базовые правила с нулевой специфичностью для продакшена.
При использовании темы по умолчанию компонент `VPIcon` из `vitepress/theme` оборачивает этот композабл (а также принимает сырую строку `{ svg }`):
```vue-html
<VPIcon icon="lucide:rocket" />
```
Иконки, отрисовываемые только на клиенте (например, внутри `<ClientOnly />`), не могут быть собраны во время сборки — вместо этого перечислите их в [`icons.include`](./site-config#icons).
## `withBase` <Badge type="info" text="хелпер" /> {#withbase}
- **Тип**: `(path: string) => string`

@ -248,6 +248,13 @@ type HeadConfig =
| [string, Record<string, string>, string]
```
Записи head из конфигурации сайта, [конфигурации локали](../guide/i18n), [конфигурации на уровне директории](#directory-level-overrides), [метаданные](./frontmatter-config#head) и [`transformHead`](#transformhead) объединяются в этом порядке. Более поздняя запись заменяет более раннюю с тем же ключом вместо того, чтобы добавляться к ней:
- Любой элемент с атрибутом `id` идентифицируется по своему `id`.
- Элемент `meta` без `id` идентифицируется по своему первому атрибуту, отличному от `content` (например, `name`, `property`, `http-equiv`), и значению этого атрибута.
Остальные элементы никогда не считаются повторяющимися и не заменяют друг друга. Чтобы отрисовать несколько тегов `meta`, которые имели бы одинаковый ключ, например несколько `<meta name="author">`, задайте каждому из них уникальный `id`
#### Пример: Добавление значка сайта {#example-adding-a-favicon}
```ts
@ -367,6 +374,8 @@ export default {
Базовый URL-адрес, по которому будет развёрнут сайт. Этот параметр необходимо задать, если вы планируете развернуть свой сайт по подпути, например, для страниц GitHub. Если вы планируете развернуть свой сайт на `https://foo.github.io/bar/`, то вам следует установить base на `'/bar/'`. Он всегда должен начинаться и заканчиваться косой чертой.
Единственное исключение — `'./'`, которое создаёт [переносимую сборку](../guide/deploy#relocatable-builds-relative-base): страницы ссылаются на всё относительно своего собственного расположения, поэтому один и тот же результат сборки работает из любого подпути (шлюзы IPFS, архивы) без пересборки и остаётся просматриваемым при открытии напрямую из файловой системы.
Параметр `base` автоматически добавляется ко всем URL, которые начинаются с `/` в других опциях, поэтому вам нужно указать его только один раз.
```ts
@ -375,6 +384,8 @@ export default {
}
```
Также может быть задан для отдельной сборки с помощью `vitepress build --base /base/`.
## Маршрутизация {#routing}
### cleanUrls {#cleanurls}
@ -456,6 +467,44 @@ export default {
}
```
### assetsBase
- Тип: `string`
- По умолчанию: `undefined`
Префикс URL, с которого раздаются сгенерированные ресурсы (всё, что находится под [`assetsDir`](#assetsdir)) — обычно CDN. Должен быть абсолютным URL, URL без указания протокола (начинающимся с `//`) или абсолютным путём от корня сайта (начинающимся с `/`); при отсутствии завершающего слэша он добавляется автоматически.
```ts
export default {
base: '/',
assetsBase: 'https://cdn.example.com/'
// скрипты, стили, шрифты и импортированные изображения разрешаются в
// https://cdn.example.com/assets/*
}
```
Итоговый URL ресурса — это `assetsBase`, объединённый с относительным путём файла в выводе, поэтому CDN должен зеркалировать структуру `outDir` (загрузите `outDir/assets`, чтобы он был доступен по адресу `<assetsBase>/assets/*`). HTML-страницы, ссылки Markdown, файлы [`public`](../guide/asset-handling#the-public-directory) и `hashmap.json` остаются на [`base`](#base).
Когда `assetsBase` указывает на другой домен, VitePress добавляет `crossorigin` к сгенерированным тегам script и preload — CDN должен отправлять `Access-Control-Allow-Origin` для домена вашего сайта (модульные скрипты всегда загружаются в режиме CORS).
Это влияет только на продакшен-сборки. Команда `vitepress preview` раздаёт значение `assetsBase`, заданное как путь от корня сайта (например, `/cdn/`), из локальной папки dist, а внешний адрес — запрашивает напрямую по настоящему URL. Значение также можно задать для отдельной сборки с помощью `vitepress build --assetsBase https://cdn.example.com/`.
### icons
- Тип: `{ include?: string[] }`
Опции для сгенерированных стилей иконок. Сборка собирает каждую иконку iconify, отрисованную во время SSR. Имена указываются полностью в формате `collection:name`, разрешаясь относительно пакетов `@iconify-json/*`, объявленных в зависимостях вашего проекта.
Иконки, отрисовываемые только на клиенте — внутри `<ClientOnly>` или после гидратации — невидимы для сбора во время SSR. Перечислите их в `include`, чтобы принудительно добавить в таблицу стилей:
```ts
export default {
icons: {
include: ['mdi:home', 'simple-icons:discord']
}
}
```
### cacheDir {#cachedir}
- Тип: `string`
@ -625,6 +674,7 @@ export default {
interface SSGContext {
content: string
teleports?: Record<string, string>
vpIcons: Set<string>
[key: string]: any
}
```
@ -639,6 +689,10 @@ interface SSGContext {
Не мутируйте ничего внутри `context`.
:::
::: note
Ссылка на таблицу стилей иконок на этом этапе всё ещё содержит заглушку `vp-icons.__VP_ICONS_HASH__.css` — хеш содержимого появляется только после того, как отрендерены все страницы, и подставляется сразу после этого. Хуки, которые встраивают или проставляют отпечаток ресурсам в head, должны пропускать этот тег.
:::
```ts
export default {
async transformHead(context) {

@ -3,7 +3,7 @@ import {
type DefaultTheme,
type MarkdownLocaleOptions
} from 'vitepress'
import { version } from 'vitepress/package.json' with { type: 'json' }
import pkg from 'vitepress/package.json' with { type: 'json' }
export const markdown: MarkdownLocaleOptions = {
container: {
@ -89,7 +89,7 @@ function nav(): DefaultTheme.NavItem[] {
activeMatch: '/zh/reference/'
},
{
text: version,
text: pkg.version,
items: [
{
text: '1.6.4',

@ -1,6 +1,6 @@
{
"name": "vitepress",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.20",
"description": "Vite & Vue powered static site generator",
"keywords": [
"vite",
@ -96,86 +96,86 @@
"@docsearch/css": "^4.7.0",
"@docsearch/js": "^4.7.0",
"@docsearch/sidepanel-js": "^4.7.0",
"@iconify-json/simple-icons": "^1.2.93",
"@iconify-json/simple-icons": "^1.2.95",
"@shikijs/transformers": "^4.4.3",
"@types/markdown-it": "^14.1.2",
"@types/markdown-it": "^14.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/devtools-api": "^8.2.1",
"@vue/shared": "^3.5.41",
"@vue/shared": "^3.5.42",
"@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.4.0",
"focus-trap": "^8.2.2",
"mark.js": "8.11.1",
"minisearch": "^7.2.0",
"shiki": "^4.4.3",
"vite": "^8.2.1",
"vue": "^3.5.41"
"vite": "^8.3.0",
"vue": "^3.5.42"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@clack/prompts": "^1.7.0",
"@iconify/utils": "^3.1.4",
"@mdit-vue/plugin-component": "^3.0.2",
"@mdit-vue/plugin-frontmatter": "^3.0.2",
"@mdit-vue/plugin-headers": "^3.0.2",
"@mdit-vue/plugin-sfc": "^3.0.2",
"@mdit-vue/plugin-title": "^3.0.2",
"@mdit-vue/plugin-toc": "^3.0.2",
"@mdit-vue/shared": "^3.0.2",
"@mdit/plugin-anchor": "^1.1.3",
"@mdit/plugin-attrs": "^1.1.1",
"@mdit/plugin-container": "^1.0.2",
"@mdit/plugin-emoji": "^1.1.1",
"@mdit/plugin-footnote": "^1.0.2",
"@mdit/plugin-tasklist": "^1.0.2",
"@clack/prompts": "1.7.0",
"@iconify/utils": "^3.1.7",
"@mdit-vue/plugin-component": "3.0.2",
"@mdit-vue/plugin-frontmatter": "3.0.2",
"@mdit-vue/plugin-headers": "3.0.2",
"@mdit-vue/plugin-sfc": "3.0.2",
"@mdit-vue/plugin-title": "3.0.2",
"@mdit-vue/plugin-toc": "3.0.2",
"@mdit-vue/shared": "3.0.2",
"@mdit/plugin-anchor": "1.1.3",
"@mdit/plugin-attrs": "1.1.1",
"@mdit/plugin-container": "1.0.2",
"@mdit/plugin-emoji": "1.1.1",
"@mdit/plugin-footnote": "1.0.2",
"@mdit/plugin-tasklist": "1.0.2",
"@polka/compression": "^1.0.0-next.28",
"@rolldown/pluginutils": "^1.0.1",
"@types/cross-spawn": "^6.0.6",
"@types/mark.js": "^8.11.12",
"@types/minimist": "^1.2.5",
"@types/node": "^26.2.0",
"@types/node": "^26.5.1",
"@types/picomatch": "^4.0.3",
"@types/semver": "^7.8.0",
"@volar/typescript": "^2.4.28",
"@vue/language-core": "^3.3.11",
"conventional-changelog": "^8.1.1",
"conventional-changelog-angular": "^9.2.1",
"conventional-changelog": "^8.1.3",
"conventional-changelog-angular": "^9.4.0",
"cross-spawn": "^7.0.6",
"eta": "^4.6.0",
"get-port": "^7.2.0",
"gray-matter": "^4.0.3",
"image-size": "^2.0.2",
"lint-staged": "^17.3.0",
"lint-staged": "^17.5.1",
"lru-cache": "^11.5.2",
"markdown-it": "^14.3.0",
"markdown-it": "^14.3.1",
"markdown-it-async": "^2.2.0",
"markdown-it-cjk-friendly": "^2.0.2",
"markdown-it-cjk-friendly": "^2.0.3",
"markdown-it-mathjax3": "^4.3.2",
"minimist": "^1.2.8",
"nanoid": "^6.0.1",
"obug": "^2.1.4",
"obug": "^2.2.1",
"ora": "^9.4.1",
"p-map": "^7.0.6",
"p-map": "^7.0.7",
"package-directory": "^8.2.0",
"path-to-regexp": "^6.3.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.5",
"playwright-chromium": "^1.62.1",
"picomatch": "^4.0.7",
"playwright-chromium": "^1.63.0",
"polka": "^1.0.0-next.28",
"postcss": "^8.5.6",
"postcss-selector-parser": "^7.1.5",
"postcss-selector-parser": "^7.1.6",
"prettier": "^3.9.6",
"publint": "^0.3.24",
"rolldown": "^1.2.5",
"rolldown": "^1.2.8",
"semver": "^7.8.5",
"simple-git-hooks": "^2.13.1",
"simple-git-hooks": "^2.14.0",
"sirv": "^3.0.2",
"sitemap": "^9.0.1",
"tinyglobby": "^0.2.17",
"tsdown": "^0.22.14",
"tsdown": "^0.23.0",
"typescript": "^6.0.3",
"vitest": "^4.1.10",
"vue-sfc-transformer": "^0.2.5",
"vitest": "^5.0.0",
"vue-sfc-transformer": "^0.3.0",
"vue-tsc": "^3.3.11",
"wait-on": "^9.1.0"
},

@ -1,18 +0,0 @@
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

@ -15,9 +15,6 @@ minimumReleaseAge: 1440
overrides:
esbuild: '-'
patchedDependencies:
vue-sfc-transformer: patches/vue-sfc-transformer.patch
shellEmulator: true
strictPeerDependencies: true

@ -8,7 +8,7 @@ import * as prompts from '@clack/prompts'
import { spawn } from 'cross-spawn'
import semver from 'semver'
import { version as currentVersion } from '../package.json' with { type: 'json' }
import pkg from '../package.json' with { type: 'json' }
const { inc: _inc, valid } = semver
@ -17,7 +17,7 @@ const versionIncrements = ['patch', 'minor', 'major'] as const
const tags = ['latest', 'next'] as const
const dir = fileURLToPath(new URL('.', import.meta.url))
const inc = (i: semver.ReleaseType) => _inc(currentVersion, i)
const inc = (i: semver.ReleaseType) => _inc(pkg.version, i)
const run = async (bin: string, args: string[], opts: SpawnOptions = {}) => {
const child = spawn(bin, args, { stdio: 'inherit', ...opts })
const [code, signal] = (await once(child, 'close')) as [
@ -50,7 +50,7 @@ async function main() {
if (release === 3) {
const customVersion = await prompts.text({
message: 'Input custom version',
initialValue: currentVersion
initialValue: pkg.version
})
if (prompts.isCancel(customVersion)) return cancel()
targetVersion = customVersion

@ -57,11 +57,18 @@ BASE_VAR = """\
# got a chance to pick the right CJK font. The names below cover the defaults
# of macOS / Windows / Linux (fonts-noto-cjk); families that are not installed
# are simply skipped, and sites can splice a CJK webfont between 'Inter Core'
# and the system families. Bare zh means Hans per BCP 47 likely subtags, and
# with no Traditional Chinese rule it also covers zh-Hant/zh-TW/zh-HK/zh-MO
# pages. If Hant handling is ever requested, add after the zh rule (same
# specificity, so the later rule wins; the region tags must be enumerated
# because `:lang(zh-Hant)` cannot match e.g. `lang="zh-TW"`):
# and the system families. Unlike the base stack, these do not name
# -apple-system: WebKit expands it into the system font plus CoreText's whole
# cascade list and walks that list with each font's full character map, which
# on macOS 26 / iOS 26 hands the symbols missing from the reduced PingFang
# (U+FF5C, ①, ★, ※, ...) to Apple Symbols or the Japanese UI font at a
# single weight. The CJK_FALLBACK_ANCHOR faces route them through the
# language-aware system fallback instead. Bare zh means Hans per BCP 47
# likely subtags, and with no Traditional Chinese rule it also covers
# zh-Hant/zh-TW/zh-HK/zh-MO pages. If Hant handling is ever requested, add
# after the zh rule (same specificity, so the later rule wins; the region
# tags must be enumerated because `:lang(zh-Hant)` cannot match e.g.
# `lang="zh-TW"`):
# [lang]:where(:lang(zh-Hant), :lang(zh-TW), :lang(zh-HK), :lang(zh-MO)) {
# --vp-font-family-base:
# 'Inter Core', 'PingFang TC', 'Microsoft JhengHei', 'Noto Sans CJK TC',
@ -72,22 +79,55 @@ CJK_BASE_VAR = """\
[lang]:where(:lang(zh)) {
--vp-font-family-base:
'Inter Core', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC',
-apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
BlinkMacSystemFont, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
[lang]:where(:lang(ja)) {
--vp-font-family-base:
'Inter Core', 'Hiragino Sans', 'Meiryo', 'Yu Gothic', 'Noto Sans CJK JP',
-apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
BlinkMacSystemFont, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
[lang]:where(:lang(ko)) {
--vp-font-family-base:
'Inter Core', 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans CJK KR',
-apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
BlinkMacSystemFont, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
"""
# macOS 26 / iOS 26 ship two copies of PingFang: the full one is an on-demand
# asset that CoreText flags as user-installed, which Safari keeps away from
# web content, so pages get the reduced one, from which some 1,400 glyphs -
# U+FF5C |, ①, ★, ※, kana, ... - moved to a hidden ".CJK Symbols Fallback"
# font. Only CoreText's language-aware system fallback knows about that font,
# and WebKit performs this fallback relative to the first face of the first
# family in the stack (FontCascadeFonts::findBestFallbackFont), i.e. relative
# to Inter, whose fallback chain reaches Hiragino, Helvetica and Lucida Grande
# first. The faces below make the system font that base instead: U+10FFFF is
# a noncharacter, so they never draw anything themselves and do not affect
# the primary font used for metrics (the face covering U+0020), and other
# engines never load them. WebKit orders the faces of a family last-declared-
# first, so these must remain the last 'Inter Core' rules. The result matches
# native apps: SF for the symbols it has, the CJK symbols font for the rest,
# both at the requested weight.
CJK_FALLBACK_ANCHOR = """\
@font-face {
font-family: 'Inter Core';
font-style: normal;
font-weight: 100 900;
src: local(-apple-system);
unicode-range: U+10FFFF;
}
@font-face {
font-family: 'Inter Core';
font-style: italic;
font-weight: 100 900;
src: local(-apple-system);
unicode-range: U+10FFFF;
}
"""
@ -103,25 +143,6 @@ WEBFONT_IMPORT = f"""\
/* webfont-marker-end */
"""
# The `cjkExclusions` key of the json lists characters that have both a
# Western (proportional) and an East Asian (em-square) form with no
# encoding-level distinction - mostly East Asian Ambiguous punctuation and
# symbols (UAX #11). The generated 'Inter Core' faces reuse the same font files
# but leave these out of their unicode-ranges so that CJK fonts render them
# in CJK documents. References:
# https://www.unicode.org/L2/L2014/14006-sv-western-vs-cjk.pdf
# https://www.unicode.org/L2/L2018/18013-svs-proposal.pdf
# https://www.unicode.org/L2/L2018/18073-svs-proposal.pdf
# https://www.unicode.org/L2/L2023/23212r-quotes-svs-proposal.pdf
# https://github.com/w3c/clreq/blob/gh-pages/local.css
# & U+2015 (used like U+2014 in Japanese), U+203B (Japanese reference mark),
# U+007E (zh's request; both forms are fine in ja because it is unused there)
CJK_COMMENT = """\
/* 'Inter Core' reuses the files above, but leaves out characters that should
be rendered by CJK fonts in CJK documents - see scripts/subsetFonts.py */
"""
def parse_ranges(value: str) -> set[int]:
cps: set[int] = set()
for part in value.split(","):
@ -175,6 +196,27 @@ def check_coverage(release: Path, subsets: dict[str, str]) -> None:
sys.exit("add the missing codepoints to a subset in scripts/fontSubsets.json")
# U+FE0E VARIATION SELECTOR-15 requests the text presentation of a character
# that also has an emoji form; markdown-it's footnote back-reference is one
# ("↩︎" is U+21A9 U+FE0E). WebKit shapes such a sequence only with a font that
# has a glyph for every code point in it, and Inter maps nothing to U+FE0E, so
# Safari passes over Inter and draws the arrow with -apple-system instead - on
# iOS a visibly thinner, smaller glyph (vuejs/vitepress#5428). Mapping the
# selector to Inter's empty, zero-advance ZERO WIDTH SPACE glyph in every face
# keeps the sequence in Inter without adding a glyph, so the variable-font
# tables stay untouched. U+FE0F, the emoji selector, is left unmapped on
# purpose: "↩️" should keep falling through to the emoji font.
ZERO_WIDTH_SPACE = 0x200B
TEXT_PRESENTATION_SELECTOR = 0xFE0E
def map_text_presentation_selector(font: TTFont) -> None:
zwsp = font.getBestCmap()[ZERO_WIDTH_SPACE]
for table in font["cmap"].tables:
if table.isUnicode():
table.cmap[TEXT_PRESENTATION_SELECTOR] = zwsp
def build_subsets(release: Path, subsets: dict[str, str]) -> None:
for style, (file, _) in STYLES.items():
for name, value in subsets.items():
@ -188,8 +230,9 @@ def build_subsets(release: Path, subsets: dict[str, str]) -> None:
options.name_IDs = [*options.name_IDs, 13, 14]
font = subset.load_font(release / file, options)
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=parse_ranges(value))
subsetter.populate(unicodes=parse_ranges(value) | {ZERO_WIDTH_SPACE})
subsetter.subset(font)
map_text_presentation_selector(font)
buf = io.BytesIO()
subset.save_font(font, buf, options)
out = FONTS_DIR / f"inter-{style}-{name}.woff2"
@ -215,6 +258,19 @@ def face(family: str, css_style: str, file: str, ranges: str) -> str:
)
# The `cjkExclusions` key of the json lists characters that have both a
# Western (proportional) and an East Asian (em-square) form with no
# encoding-level distinction - mostly East Asian Ambiguous punctuation and
# symbols (UAX #11). The generated 'Inter Core' faces reuse the same font files
# but leave these out of their unicode-ranges so that CJK fonts render them
# in CJK documents. References:
# https://www.unicode.org/L2/L2014/14006-sv-western-vs-cjk.pdf
# https://www.unicode.org/L2/L2018/18013-svs-proposal.pdf
# https://www.unicode.org/L2/L2018/18073-svs-proposal.pdf
# https://www.unicode.org/L2/L2023/23212r-quotes-svs-proposal.pdf
# https://github.com/w3c/clreq/blob/gh-pages/local.css
# & U+2015 (used like U+2014 in Japanese), U+203B (Japanese reference mark),
# U+007E (zh's request; both forms are fine in ja because it is unused there)
def write_css(subsets: dict[str, str], cjk_exclusions: set[int]) -> None:
inter = []
cjk = []
@ -232,8 +288,9 @@ def write_css(subsets: dict[str, str], cjk_exclusions: set[int]) -> None:
f"{WEBFONT_IMPORT}\n"
"/* Generated by scripts/subsetFonts.py from scripts/fontSubsets.json */\n\n"
+ "\n".join(inter)
+ f"\n{CJK_COMMENT}\n"
+ "\n"
+ "\n".join(cjk)
+ f"\n{CJK_FALLBACK_ANCHOR}"
+ f"\n{BASE_VAR}\n{CJK_BASE_VAR}"
)

@ -61,7 +61,7 @@ async function copyToClipboard(text: string) {
element.style.contain = 'strict'
element.style.position = 'absolute'
element.style.left = '-9999px'
element.style.insetInlineStart = '-9999px'
element.style.fontSize = '12pt' // Prevent zooming on iOS
const selection = document.getSelection()

@ -10,6 +10,12 @@ import { inBrowser, pathToFile } from '../utils'
const hasFetched = new Set<string>()
const createLink = () => document.createElement('link')
const getLinkUrl = (link: HTMLAnchorElement | SVGAElement) =>
new URL(
link.href instanceof SVGAnimatedString ? link.href.animVal : link.href,
link.baseURI
)
const viaDOM = (url: string) => {
const link = createLink()
link.rel = `prefetch`
@ -64,9 +70,9 @@ export function usePrefetch() {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const link = entry.target as HTMLAnchorElement
const link = entry.target as HTMLAnchorElement | SVGAElement
observer!.unobserve(link)
const { pathname } = link
const { pathname } = getLinkUrl(link)
if (!hasFetched.has(pathname)) {
hasFetched.add(pathname)
const pageChunkPath = pathToFile(pathname)
@ -80,12 +86,7 @@ export function usePrefetch() {
document
.querySelectorAll<HTMLAnchorElement | SVGAElement>('#app a')
.forEach((link) => {
const { hostname, pathname } = new URL(
link.href instanceof SVGAnimatedString
? link.href.animVal
: link.href,
link.baseURI
)
const { hostname, pathname } = getLinkUrl(link)
const extMatch = pathname.match(/\.\w+$/)
if (extMatch && extMatch[0] !== '.html') {
return
@ -94,7 +95,7 @@ export function usePrefetch() {
if (
// only prefetch same tab navigation, since a new tab will load
// the lean js chunk instead.
link.target !== '_blank' &&
link.getAttribute('target') !== '_blank' &&
// only prefetch inbound links
hostname === location.hostname
) {

@ -4,7 +4,6 @@ import {
createSSRApp,
defineComponent,
h,
onMounted,
watchEffect,
type App
} from 'vue'
@ -45,15 +44,16 @@ const VitePressApp = defineComponent({
setup() {
const { site, lang, dir } = useData()
// change the language on the HTML element based on the current lang
onMounted(() => {
// keep the html element's lang and dir in sync with the page, before
// the theme mounts so anything it measures already has the right direction
if (inBrowser) {
watchEffect(() => {
document.documentElement.lang = lang.value
if (dir.value !== false) {
document.documentElement.dir = dir.value
}
})
})
}
if (import.meta.env.PROD && site.value.router.prefetchLinks) {
// in prod mode, enable intersectionObserver based pre-fetch
@ -100,6 +100,12 @@ export async function createApp() {
}
})
// set before enhanceApp so users can still disable it or take over with their own errorHandler;
// unhandled errors then fail the build instead of silently shipping broken pages
if (import.meta.env.SSR) {
app.config.throwUnhandledErrorInProduction = true
}
if (Theme.enhanceApp) {
await Theme.enhanceApp({
app,

@ -7,6 +7,7 @@ import {
inBrowser,
isRelativeBase,
joinPath,
pageChunkPath,
sanitizeFileName,
type Awaitable
} from '../shared'
@ -84,7 +85,7 @@ export function pathToFile(path: string) {
pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()]
}
if (!pageHash) return null
pagePath = `${__ASSETS_BASE__ || base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js`
pagePath = `${__ASSETS_BASE__ || base}${__ASSETS_DIR__}/${pageChunkPath(pagePath, pageHash)}`
} else {
// ssr build uses much simpler name mapping
pagePath = `./${sanitizeFileName(

@ -11,13 +11,15 @@ const { currentLang } = useLangs()
<template>
<div class="NotFound">
<p class="code">{{ theme.notFound?.code ?? '404' }}</p>
<h1 class="title">{{ theme.notFound?.title ?? 'PAGE NOT FOUND' }}</h1>
<h1 class="title">
<bdi>{{ theme.notFound?.title ?? 'PAGE NOT FOUND' }}</bdi>
</h1>
<div class="divider" />
<blockquote class="quote">
{{
<bdi>{{
theme.notFound?.quote ??
"But if you don't change your direction, and if you keep looking, you may end up where you are heading."
}}
}}</bdi>
</blockquote>
<div class="action">
@ -26,7 +28,7 @@ const { currentLang } = useLangs()
:href="withBase(theme.notFound?.link ?? currentLang.link)"
:aria-label="theme.notFound?.linkLabel ?? 'go to home'"
>
{{ theme.notFound?.linkText ?? 'Take me home' }}
<bdi>{{ theme.notFound?.linkText ?? 'Take me home' }}</bdi>
</a>
</div>
</div>

@ -13,12 +13,7 @@ defineProps<{
<style scoped>
.VPBackdrop {
position: fixed;
top: 0;
/*rtl:ignore*/
right: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
inset: 0;
z-index: var(--vp-z-index-backdrop);
background: var(--vp-backdrop-bg-color);
transition: opacity 0.5s;

@ -8,7 +8,7 @@ withDefaults(defineProps<{
</script>
<template>
<span class="VPBadge" :class="type">
<span class="VPBadge" :class="type" dir="auto">
<slot>{{ text }}</slot>
</span>
</template>
@ -16,7 +16,7 @@ withDefaults(defineProps<{
<style>
.VPBadge {
display: inline-block;
margin-left: 0.125rem;
margin-inline-start: 0.125rem;
border: 1px solid transparent;
border-radius: 0.75rem;
padding: 0 0.625rem;
@ -40,7 +40,8 @@ withDefaults(defineProps<{
.vp-doc h1 > .VPBadge,
.vp-doc h2 > .VPBadge {
margin: 0 0 0 0.125rem;
margin: 0;
margin-inline-start: 0.125rem;
vertical-align: middle;
}

@ -42,7 +42,9 @@ const component = computed(() => {
<style scoped>
.VPButton {
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
text-align: center;
font-weight: 600;
@ -56,16 +58,16 @@ const component = computed(() => {
}
.VPButton.medium {
height: 2.5rem;
border-radius: 1.25rem;
padding: 0 1.25rem;
line-height: 2.7142857;
font-size: 0.875rem;
}
.VPButton.big {
height: 3rem;
border-radius: 1.5rem;
padding: 0 1.5rem;
line-height: 2.875;
font-size: 1rem;
}

@ -86,14 +86,14 @@ function isRegistered(component: string): boolean {
.VPContent.has-sidebar {
margin: var(--vp-layout-top-height, 0px) 0 0;
padding-left: var(--vp-sidebar-width);
padding-inline-start: var(--vp-sidebar-width);
}
}
@media (min-width: 90rem) {
.VPContent.has-sidebar {
padding-right: calc((100% - var(--vp-layout-max-width)) / 2);
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
padding-inline-end: calc((100% - var(--vp-layout-max-width)) / 2);
padding-inline-start: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
}
}
</style>

@ -128,15 +128,15 @@ const pageName = computed(() => {
display: none;
order: 2;
flex-grow: 1;
padding-left: 2rem;
padding-inline-start: 2rem;
width: 100%;
max-width: 16rem;
}
.left-aside {
order: 1;
padding-left: unset;
padding-right: 2rem;
padding-inline-start: unset;
padding-inline-end: 2rem;
}
.aside-container {

@ -51,8 +51,8 @@ useActiveAnchor(container, marker)
.content {
position: relative;
border-left: 1px solid var(--vp-c-divider);
padding-left: 1rem;
border-inline-start: 1px solid var(--vp-c-divider);
padding-inline-start: 1rem;
font-size: 0.8125rem;
font-weight: 500;
}
@ -60,7 +60,7 @@ useActiveAnchor(container, marker)
.outline-marker {
position: absolute;
top: 2rem;
left: -1px;
inset-inline-start: -1px;
z-index: 0;
opacity: 0;
width: 2px;

@ -61,7 +61,7 @@ const showFooter = computed(
class="desc"
v-html="theme.docFooter?.prev || 'Previous page'"
></span>
<span class="title" v-html="control.prev.text"></span>
<span class="title"><bdi v-html="control.prev.text" /></span>
</VPLink>
</div>
<div class="pager">
@ -76,7 +76,7 @@ const showFooter = computed(
class="desc"
v-html="theme.docFooter?.next || 'Next page'"
></span>
<span class="title" v-html="control.next.text"></span>
<span class="title"><bdi v-html="control.next.text" /></span>
</VPLink>
</div>
</nav>
@ -117,7 +117,7 @@ const showFooter = computed(
}
.edit-link-icon {
margin-right: 0.5rem;
margin-inline-end: 0.5rem;
}
.prev-next {
@ -149,8 +149,8 @@ const showFooter = computed(
}
.pager-link.next {
margin-left: auto;
text-align: right;
margin-inline-start: auto;
text-align: end;
}
.desc {

@ -46,8 +46,8 @@ onMounted(() => {
<template>
<p class="VPLastUpdated">
{{ theme.lastUpdated?.text || 'Last updated' }}:
<time ref="timeRef" :datetime="isoDatetime">{{ datetime }}</time>
<bdi>{{ theme.lastUpdated?.text || 'Last updated' }}:</bdi>
<time ref="timeRef" dir="auto" :datetime="isoDatetime">{{ datetime }}</time>
</p>
</template>

@ -11,7 +11,7 @@ defineProps<{
<ul class="VPDocOutlineItem" :class="root ? 'root' : 'nested'">
<li v-for="{ children, link, title } in headers">
<a class="outline-link" :href="link" :title>
{{ title }}
<bdi>{{ title }}</bdi>
</a>
<template v-if="children?.length">
<VPDocOutlineItem :headers="children" />
@ -27,8 +27,7 @@ defineProps<{
}
.nested {
padding-right: 1rem;
padding-left: 1rem;
padding-inline: 1rem;
}
.outline-link {
@ -52,6 +51,6 @@ defineProps<{
}
.outline-link.nested {
padding-left: 0.8125rem;
padding-inline-start: 0.8125rem;
}
</style>

@ -41,14 +41,14 @@ defineProps<{
:width="icon.width || 48"
/>
<div v-else-if="icon" class="icon" v-html="icon"></div>
<h2 class="title" v-html="title"></h2>
<h2 class="title"><bdi v-html="title" /></h2>
<ul v-if="Array.isArray(details)" class="details">
<li v-for="item in details" :key="item" v-html="item"></li>
<li v-for="item in details" :key="item"><bdi v-html="item" /></li>
</ul>
<p v-else-if="details" class="details" v-html="details"></p>
<p v-else-if="details" class="details"><bdi v-html="details" /></p>
<div v-if="linkText" class="link-text">
<p class="link-text-value">
{{ linkText }} <span class="vpi-arrow-right link-text-icon" />
<bdi>{{ linkText }}</bdi> <span class="vpi-arrow-right link-text-icon" />
</p>
</div>
</article>
@ -110,7 +110,7 @@ defineProps<{
ul.details {
list-style-type: disc;
padding-left: 0.875rem;
padding-inline-start: 0.875rem;
}
.link-text {
@ -126,6 +126,6 @@ ul.details {
}
.link-text-icon {
margin-left: 0.375rem;
margin-inline-start: 0.375rem;
}
</style>

@ -101,7 +101,7 @@ useEventListener('pointerdown', (e) => {
>
<span v-if="button || icon" class="text">
<span v-if="icon" :class="[icon, 'option-icon']" aria-hidden="true" />
<span v-if="button" v-html="button"></span>
<span v-if="button" dir="auto" v-html="button"></span>
<span class="vpi-chevron-down text-icon" aria-hidden="true" />
</span>
@ -179,7 +179,7 @@ useEventListener('pointerdown', (e) => {
}
.text-icon {
margin-left: 0.25rem;
margin-inline-start: 0.25rem;
font-size: 0.875rem;
}
@ -191,7 +191,7 @@ useEventListener('pointerdown', (e) => {
.menu {
position: absolute;
top: calc(var(--vp-nav-height) / 2 + 1.25rem);
right: 0;
inset-inline-end: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s;

@ -13,16 +13,12 @@ const { hasSidebar } = useLayout()
:class="{ 'has-sidebar': hasSidebar }"
>
<div class="container">
<p
v-if="theme.footer.message"
class="message"
v-html="theme.footer.message"
></p>
<p
v-if="theme.footer.copyright"
class="copyright"
v-html="theme.footer.copyright"
></p>
<p v-if="theme.footer.message" class="message">
<bdi v-html="theme.footer.message" />
</p>
<p v-if="theme.footer.copyright" class="copyright">
<bdi v-html="theme.footer.copyright" />
</p>
</div>
</footer>
</template>

@ -35,10 +35,10 @@ const { heroImageSlotExists } = inject(
<slot name="home-hero-info-before" />
<slot name="home-hero-info">
<h1 class="heading">
<span v-if="name" v-html="name" class="name clip"></span>
<span v-if="text" v-html="text" class="text"></span>
<span v-if="name" dir="auto" v-html="name" class="name clip"></span>
<span v-if="text" dir="auto" v-html="text" class="text"></span>
</h1>
<p v-if="tagline" v-html="tagline" class="tagline"></p>
<p v-if="tagline" class="tagline"><bdi v-html="tagline" /></p>
</slot>
<slot name="home-hero-info-after" />
@ -116,7 +116,7 @@ const { heroImageSlotExists } = inject(
@media (min-width: 60rem) {
.VPHero.has-image .container {
text-align: left;
text-align: start;
}
}
@ -293,22 +293,19 @@ const { heroImageSlotExists } = inject(
align-items: center;
width: 100%;
height: 100%;
/*rtl:ignore*/
transform: translate(-2rem, -2rem);
transform: translate(calc(-2rem * var(--vp-direction-multiplier)), -2rem);
}
}
.image-bg {
position: absolute;
top: 50%;
/*rtl:ignore*/
left: 50%;
border-radius: 50%;
width: 12rem;
height: 12rem;
background-image: var(--vp-home-hero-image-background-image);
filter: var(--vp-home-hero-image-filter);
/*rtl:ignore*/
transform: translate(-50%, -50%);
}
@ -329,14 +326,12 @@ const { heroImageSlotExists } = inject(
:deep(.image-src) {
position: absolute;
top: 50%;
/*rtl:ignore*/
left: 50%;
max-width: 12rem;
max-height: 12rem;
width: 100%;
height: 100%;
object-fit: contain;
/*rtl:ignore*/
transform: translate(-50%, -50%);
}

@ -28,8 +28,7 @@
/* stretch to full viewport width, overflow is clipped by .VPHome */
.vp-doc :deep(.VPHomeSponsors),
.vp-doc :deep(.VPTeamPage) {
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
margin-inline: calc(50% - 50vw);
}
.vp-doc :deep(.VPTeamPage a) {

@ -68,8 +68,7 @@ const isScrolled = computed(() => y.value >= navHeight.value)
.VPLocalNav {
position: sticky;
top: 0;
/*rtl:ignore*/
left: 0;
inset-inline-start: 0;
z-index: var(--vp-z-index-local-nav);
border-bottom: 1px solid var(--vp-local-nav-divider-color);
padding-top: var(--vp-layout-top-height, 0px);
@ -104,7 +103,7 @@ const isScrolled = computed(() => y.value >= navHeight.value)
}
.VPLocalNav.has-sidebar {
padding-left: var(--vp-sidebar-width);
padding-inline-start: var(--vp-sidebar-width);
}
.VPLocalNav.empty {
@ -146,7 +145,7 @@ const isScrolled = computed(() => y.value >= navHeight.value)
}
.menu-icon {
margin-right: 0.5rem;
margin-inline-end: 0.5rem;
font-size: 0.875rem;
}

@ -129,9 +129,9 @@ function scrollToTop() {
.icon {
display: inline-block;
vertical-align: middle;
margin-left: 0.125rem;
margin-inline-start: 0.125rem;
font-size: 0.875rem;
transform: rotate(0) /*rtl:rotate(180deg)*/;
transform: rotate(0);
transition: transform 0.25s;
}
@ -146,15 +146,13 @@ function scrollToTop() {
}
.open > .icon {
/*rtl:ignore*/
transform: rotate(90deg);
}
.items {
position: absolute;
top: 2.5rem;
right: 1rem;
left: 1rem;
inset-inline: 1rem;
display: grid;
gap: 1px;
border: 1px solid var(--vp-c-border);
@ -168,8 +166,7 @@ function scrollToTop() {
@media (min-width: 60rem) {
.items {
right: auto;
left: calc(var(--vp-sidebar-width) + 2rem);
inset-inline: calc(var(--vp-sidebar-width) + 2rem) auto;
width: 20rem;
}
}

@ -573,11 +573,11 @@ function onMouseMove(e: MouseEvent) {
:key="index"
class="title"
>
<span class="text" v-html="t" />
<span class="text" dir="auto" v-html="t" />
<span class="vpi-chevron-right local-search-icon" />
</span>
<span class="title main">
<span class="text" v-html="p.title" />
<span class="text" dir="auto" v-html="p.title" />
</span>
</div>
@ -917,8 +917,7 @@ function onMouseMove(e: MouseEvent) {
.excerpt-gradient-bottom {
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
inset-inline: 0;
height: 0.5rem;
background: linear-gradient(transparent, var(--vp-local-search-result-bg));
z-index: 1000;
@ -927,8 +926,7 @@ function onMouseMove(e: MouseEvent) {
.excerpt-gradient-top {
position: absolute;
top: -1px;
left: 0;
width: 100%;
inset-inline: 0;
height: 0.5rem;
background: linear-gradient(var(--vp-local-search-result-bg), transparent);
z-index: 1000;

@ -92,9 +92,10 @@ const hasSubGroups = computed(() =>
}
.VPMenuGroup > .sub-groups {
margin: 0.25rem 0 0.25rem 0.75rem;
border-left: 1px solid var(--vp-c-divider);
padding-left: 0.25rem;
margin-block: 0.25rem;
margin-inline: 0.75rem 0;
border-inline-start: 1px solid var(--vp-c-divider);
padding-inline-start: 0.25rem;
}
.VPMenuGroup .VPMenuGroup,

@ -41,7 +41,7 @@ defineOptions({ inheritAttrs: false })
:no-icon="item.noIcon"
@click="onClick"
>
<span v-html="item.text"></span>
<span dir="auto" v-html="item.text"></span>
</VPLink>
</li>
</template>
@ -67,7 +67,7 @@ defineOptions({ inheritAttrs: false })
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
text-align: left;
text-align: start;
white-space: nowrap;
transition: background-color 0.25s, color 0.25s;
}
@ -89,7 +89,7 @@ defineOptions({ inheritAttrs: false })
.VPNavScreen .link {
display: block;
margin-left: 0.75rem;
margin-inline-start: 0.75rem;
border-radius: 0;
padding: 0;
font-weight: 400;

@ -42,8 +42,7 @@ watchEffect(() => {
.VPNav {
position: relative;
top: var(--vp-layout-top-height, 0px);
/*rtl:ignore*/
left: 0;
inset-inline-start: 0;
z-index: var(--vp-z-index-nav);
width: 100%;
pointer-events: none;

@ -65,7 +65,8 @@ const labelId = useId()
justify-content: space-between;
align-items: center;
border-radius: 0.5rem;
padding: 0.75rem 0.875rem 0.75rem 1rem;
padding-block: 0.75rem;
padding-inline: 1rem 0.875rem;
background-color: var(--vp-c-bg-soft);
}

@ -89,7 +89,7 @@ const overflow = provideNavOverflow({
height: var(--vp-nav-height);
pointer-events: none;
white-space: nowrap;
/* left edge of the background surface and divider — on doc pages the
/* inline-start edge of the background surface and divider — on doc pages the
sidebar column paints its own surface up to this offset */
--vp-nav-col-offset: 0px;
}
@ -100,9 +100,8 @@ const overflow = provideNavOverflow({
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: var(--vp-nav-col-offset);
inset-inline: var(--vp-nav-col-offset) 0;
z-index: -1;
background-color: var(--vp-nav-bg-color);
backdrop-filter: var(--vp-nav-backdrop-filter);
@ -152,7 +151,8 @@ const overflow = provideNavOverflow({
}
.wrapper {
padding: 0 0.5rem 0 1.5rem;
padding-block: 0;
padding-inline: 1.5rem 0.5rem;
}
@media (min-width: 48rem) {
@ -193,14 +193,10 @@ const overflow = provideNavOverflow({
}
@media (min-width: 60rem) {
/* outside home the title column matches the sidebar column, so search and
menu sit at the same spot on every doc page; on home the title keeps its
natural width and search sits right next to it */
.VPNavBar:not(.home) .title {
min-width: calc(var(--vp-sidebar-width) - 2rem);
}
/* reserve the sidebar column only when it is present; otherwise the title
keeps its natural width and search sits right next to it */
.VPNavBar.has-sidebar .title {
min-width: calc(var(--vp-sidebar-width) - 2rem);
max-width: calc(var(--vp-sidebar-width) - 2rem);
}
}
@ -232,7 +228,7 @@ const overflow = provideNavOverflow({
into the middle; with a menu present its flex-grow wins and this
margin resolves to zero */
.content-body > .search {
margin-right: auto;
margin-inline-end: auto;
}
}
@ -242,15 +238,14 @@ const overflow = provideNavOverflow({
visibility: hidden;
position: absolute;
top: 0;
left: 0;
inset-inline-start: 0;
max-width: 100%;
overflow: hidden;
}
/* separators between whichever cluster units are currently in the bar */
.content-body > :where(.menu, .translations, .appearance, .social-links) + :where(.translations, .appearance, .social-links)::before {
margin-right: 0.5rem;
margin-left: 0.5rem;
margin-inline: 0.5rem;
width: 1px;
height: 1.5rem;
background-color: var(--vp-c-divider);
@ -258,15 +253,15 @@ const overflow = provideNavOverflow({
}
.content-body > :where(.menu, .translations) + .appearance::before {
margin-right: 1rem;
margin-inline-end: 1rem;
}
.content-body > .appearance + .social-links::before {
margin-left: 1rem;
margin-inline-start: 1rem;
}
.social-links {
margin-right: -0.5rem;
margin-inline-end: -0.5rem;
}
.divider {
@ -279,7 +274,7 @@ const overflow = provideNavOverflow({
transform: translateZ(0);
width: 100%;
height: 1px;
padding-left: var(--vp-nav-col-offset);
padding-inline-start: var(--vp-nav-col-offset);
}
/* the sidebar-column segment of the bottom rule — inset from the column
@ -288,7 +283,7 @@ const overflow = provideNavOverflow({
content: "";
position: absolute;
top: 0;
left: calc(var(--vp-nav-col-offset) - var(--vp-sidebar-width) + 2rem);
inset-inline-start: calc(var(--vp-nav-col-offset) - var(--vp-sidebar-width) + 2rem);
width: calc(var(--vp-sidebar-width) - 4rem);
height: 1px;
background-color: var(--vp-c-divider);

@ -89,7 +89,7 @@ const hasContent = computed(
<style scoped>
.VPNavBarExtra {
display: none;
margin-right: -0.75rem;
margin-inline-end: -0.75rem;
}
@media (min-width: 48rem) {

@ -61,11 +61,12 @@ watchEffect(() => {
width: 1rem;
height: 0.875rem;
overflow: hidden;
scale: var(--vp-direction-multiplier) 1;
}
.VPNavBarHamburger:hover .top { top: 0; left: 0; transform: translateX(0.25rem); }
.VPNavBarHamburger:hover .middle { top: 0.375rem; left: 0; transform: translateX(0); }
.VPNavBarHamburger:hover .bottom { top: 0.75rem; left: 0; transform: translateX(0.5rem); }
.VPNavBarHamburger:hover .top { top: 0; transform: translateX(0.25rem); }
.VPNavBarHamburger:hover .middle { top: 0.375rem; transform: translateX(0); }
.VPNavBarHamburger:hover .bottom { top: 0.75rem; transform: translateX(0.5rem); }
.VPNavBarHamburger.active .top { top: 0.375rem; transform: translateX(0) rotate(225deg); }
.VPNavBarHamburger.active .middle { top: 0.375rem; transform: translateX(1rem); }
@ -82,13 +83,14 @@ watchEffect(() => {
.middle,
.bottom {
position: absolute;
inset-inline-start: 0;
width: 1rem;
height: 0.125rem;
background-color: var(--vp-c-text-1);
transition: top 0.25s, background-color 0.5s, transform 0.25s;
}
.top { top: 0; left: 0; transform: translateX(0); }
.middle { top: 0.375rem; left: 0; transform: translateX(0.5rem); }
.bottom { top: 0.75rem; left: 0; transform: translateX(0.25rem); }
.top { top: 0; transform: translateX(0); }
.middle { top: 0.375rem; transform: translateX(0.5rem); }
.bottom { top: 0.75rem; transform: translateX(0.25rem); }
</style>

@ -204,13 +204,13 @@ function isEditingContent(event: KeyboardEvent): boolean {
@media (min-width: 48rem) {
.VPNavBarSearch {
gap: 0.5rem;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
}
}
@media (min-width: 60rem) {
.VPNavBarSearch {
padding-left: 2rem;
padding-inline-start: 2rem;
}
}
</style>

@ -67,6 +67,8 @@ kbd {
display: flex;
align-items: center;
gap: 0.25rem;
/* the shortcut reads the same way in every direction */
direction: ltr;
padding: 0.25rem 0.375rem;
border: 1px solid var(--vp-c-divider);
border-radius: 0.25rem;

@ -49,7 +49,7 @@ const textTitle = computed(() => {
>
<slot name="nav-bar-title-before" />
<VPImage v-if="theme.logo" class="logo" :image="theme.logo" />
<span v-if="theme.siteTitle" v-html="theme.siteTitle"></span>
<span v-if="theme.siteTitle" dir="auto" v-html="theme.siteTitle"></span>
<span v-else-if="theme.siteTitle === undefined">{{ site.title }}</span>
<slot name="nav-bar-title-after" />
</a>
@ -89,7 +89,7 @@ const textTitle = computed(() => {
:deep(.logo) {
flex: none;
margin-right: 0.5rem;
margin-inline-end: 0.5rem;
height: var(--vp-nav-logo-height);
}
</style>

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

Loading…
Cancel
Save