feat(theme): native rtl support (#5437)

Co-authored-by: Rami Yushuvaev <ramiy@elementor.com>
Co-authored-by: ws-rush <wusaby.rush@zohomail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
pull/5439/head
Divyansh Singh 2 weeks ago committed by GitHub
parent 5f69cc9d90
commit 304207c497
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,146 @@
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 }
})
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 page.evaluate(() =>
document.querySelector('#section-one')!.scrollIntoView()
)
await page.waitForSelector('.outline-link.active[href="#section-one"]')
const before = await box('.outline-marker')
expect(before.x).toBeGreaterThan(outline.x + outline.width / 2)
await page.evaluate(() =>
document.querySelector('#section-two')!.scrollIntoView()
)
await page.waitForSelector('.outline-link.active[href="#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)
await page.locator('.VPLocalNav .menu').click()
await page.waitForSelector('.VPSidebar.open')
// wait for the slide-in transition to settle against the right edge
await page.waitForFunction(() => {
const { right } = document
.querySelector('.VPSidebar')!
.getBoundingClientRect()
return Math.abs(right - innerWidth) < 1
})
const open = await box('.VPSidebar')
expect(open.x).toBeLessThan(375)
})
})

@ -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([])
})
})

@ -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>
"

@ -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;

@ -141,6 +141,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.
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>
dir: Ref<'ltr' | 'rtl' | 'auto'>
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). See [RTL Support](../guide/i18n#rtl-support).
```ts
export default {
dir: 'rtl'
}
```
### base
- Type: `string`

@ -13,7 +13,6 @@
"@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.5"

@ -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;

@ -294,9 +294,6 @@ importers:
open-cli:
specifier: ^9.0.0
version: 9.0.0(supports-color@7.2.0)
postcss-rtlcss:
specifier: ^6.0.0
version: 6.0.0(postcss@8.5.28)
vitepress:
specifier: workspace:*
version: link:..
@ -2127,12 +2124,6 @@ packages:
resolution: {integrity: sha512-ryc8D/B5E/YnlWHkNMnRvNntPc4GwU1/+iDBjiXVz1SUjDRqlxYX5Ic0IaDLA/cQ+g7/x+jUzEjv2K16u1J+wA==}
engines: {node: '>=8'}
postcss-rtlcss@6.0.0:
resolution: {integrity: sha512-iJMOT4vM6EOnvNUm+uZycpqqTeHCTMGPsyxIHloVUioECgKIrvtWtPIczc5NMc14x5ZJGadwUs0L1o6iZDCldg==}
engines: {node: '>=18.0.0'}
peerDependencies:
postcss: ^8.4.21
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
@ -2244,11 +2235,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rtlcss@4.3.0:
resolution: {integrity: sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==}
engines: {node: '>=12.0.0'}
hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
@ -2372,10 +2358,6 @@ packages:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
@ -4555,11 +4537,6 @@ snapshots:
'@polka/url': 1.0.0-next.29
trouter: 4.0.0
postcss-rtlcss@6.0.0(postcss@8.5.28):
dependencies:
postcss: 8.5.28
rtlcss: 4.3.0
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
@ -4689,13 +4666,6 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.8
'@rolldown/binding-win32-x64-msvc': 1.2.8
rtlcss@4.3.0:
dependencies:
escalade: 3.2.0
picocolors: 1.1.1
postcss: 8.5.28
strip-json-comments: 3.1.1
run-applescript@7.1.0: {}
run-parallel@1.2.0:
@ -4819,8 +4789,6 @@ snapshots:
strip-bom-string@1.0.0: {}
strip-json-comments@3.1.1: {}
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0

@ -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()

@ -4,7 +4,6 @@ import {
createSSRApp,
defineComponent,
h,
onMounted,
watchEffect,
type App
} from 'vue'
@ -45,13 +44,14 @@ 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
document.documentElement.dir = dir.value
})
})
}
if (import.meta.env.PROD && site.value.router.prefetchLinks) {
// in prod mode, enable intersectionObserver based pre-fetch

@ -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;
}

@ -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) {
@ -228,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;
}
}
@ -238,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);
@ -254,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 {
@ -275,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
@ -284,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>

@ -64,7 +64,7 @@ function isVisible(index: number) {
visibility: hidden;
position: absolute;
top: 0;
left: 0;
inset-inline-start: 0;
max-width: 100%;
overflow: hidden;
}

@ -86,7 +86,7 @@ function toggle() {
:aria-controls="groupId"
@click="toggle"
>
<span class="button-text" v-html="item.text"></span>
<span class="button-text" dir="auto" v-html="item.text"></span>
<span class="vpi-plus button-icon" aria-hidden="true" />
</button>
@ -118,7 +118,6 @@ function toggle() {
}
.VPNavScreenMenuGroup.open .button-icon {
/*rtl:ignore*/
transform: rotate(45deg);
}
@ -126,7 +125,8 @@ function toggle() {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.25rem 0.6875rem 0;
padding-block: 0.75rem 0.6875rem;
padding-inline: 0 0.25rem;
width: 100%;
line-height: 1.7142857;
font-size: 0.875rem;

@ -34,7 +34,7 @@ function onClick() {
:no-icon="item.noIcon"
@click="onClick"
>
<span v-html="item.text"></span>
<span dir="auto" v-html="item.text"></span>
</VPLink>
</template>

@ -48,12 +48,7 @@ onKeyStroke('Escape', () => {
<style scoped>
.VPNavScreen {
position: fixed;
top: 0;
/*rtl:ignore*/
right: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
inset: 0;
padding: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 1px) 2rem 0;
width: 100%;
background-color: var(--vp-nav-screen-bg-color);

@ -124,7 +124,8 @@ const localeProps = (locale: (typeof localeLinks.value)[number]) => ({
}
.VPNavBarTranslations .title {
padding: 0 1.5rem 0 0.75rem;
padding-block: 0;
padding-inline: 0.75rem 1.5rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 700;
@ -155,20 +156,21 @@ const localeProps = (locale: (typeof localeLinks.value)[number]) => ({
}
.VPNavScreenTranslations .icon.lang {
margin-right: 0.5rem;
margin-inline-end: 0.5rem;
}
.VPNavScreenTranslations .icon.chevron {
margin-left: 0.25rem;
margin-inline-start: 0.25rem;
transition: transform 0.25s;
}
.VPNavScreenTranslations.open .icon.chevron {
transform: rotate(180deg);
transform: rotate(-90deg);
}
.VPNavScreenTranslations .list {
padding: 0.25rem 0 0 1.5rem;
padding-block: 0.25rem 0;
padding-inline: 1.5rem 0;
}
.VPNavScreenTranslations .link {

@ -69,7 +69,7 @@ watch(
position: fixed;
top: var(--vp-layout-top-height, 0px);
bottom: 0;
left: 0;
inset-inline-start: 0;
z-index: var(--vp-z-index-sidebar);
padding: 2rem 2rem 6rem;
width: calc(100vw - 4rem);
@ -79,7 +79,7 @@ watch(
box-shadow: var(--vp-c-shadow-3);
overflow-x: hidden;
overflow-y: auto;
transform: translateX(-100%);
transform: translateX(calc(-100% * var(--vp-direction-multiplier)));
transition: opacity 0.5s, transform 0.25s ease;
overscroll-behavior: contain;
}
@ -110,7 +110,7 @@ watch(
@media (min-width: 90rem) {
.VPSidebar {
padding-left: max(2rem, calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2));
padding-inline-start: max(2rem, calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2));
width: calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2 + var(--vp-sidebar-width) - 2rem);
}
}
@ -119,11 +119,10 @@ watch(
.curtain {
position: sticky;
top: calc(var(--vp-nav-height) * -1);
left: 0;
inset-inline-start: 0;
z-index: 1;
margin-top: calc(var(--vp-nav-height) * -1);
margin-right: -2rem;
margin-left: -2rem;
margin-inline: -2rem;
height: var(--vp-nav-height);
background-color: var(--vp-sidebar-bg-color);
}

@ -64,9 +64,9 @@ function onItemClick() {
:rel="item.rel"
:target="item.target"
>
<component :is="textTag" class="text" v-html="item.text" />
<component :is="textTag" class="text"><bdi v-html="item.text" /></component>
</VPLink>
<component v-else :is="textTag" class="text" v-html="item.text" />
<component v-else :is="textTag" class="text"><bdi v-html="item.text" /></component>
<button
v-if="item.collapsed != null && item.items && item.items.length"
@ -116,7 +116,7 @@ function onItemClick() {
position: absolute;
top: 0.375rem;
bottom: 0.375rem;
left: calc(-1rem - 1px);
inset-inline-start: calc(-1rem - 1px);
width: 2px;
border-radius: 2px;
transition: background-color 0.25s;
@ -194,7 +194,7 @@ function onItemClick() {
display: flex;
justify-content: center;
align-items: center;
margin-right: -0.4375rem;
margin-inline-end: -0.4375rem;
width: 2rem;
height: 2rem;
color: var(--vp-c-text-3);
@ -213,13 +213,12 @@ function onItemClick() {
.caret-icon {
font-size: 1.125rem;
/*rtl:ignore*/
transform: rotate(90deg);
transition: transform 0.25s;
}
.VPSidebarItem.collapsed .caret-icon {
transform: rotate(0) /*rtl:rotate(180deg)*/;
transform: rotate(0);
}
.VPSidebarItem.level-1 .items,
@ -227,8 +226,8 @@ function onItemClick() {
.VPSidebarItem.level-3 .items,
.VPSidebarItem.level-4 .items,
.VPSidebarItem.level-5 .items {
border-left: 1px solid var(--vp-c-divider);
padding-left: 1rem;
border-inline-start: 1px solid var(--vp-c-divider);
padding-inline-start: 1rem;
}
.VPSidebarItem.collapsed .items {

@ -27,7 +27,7 @@ watch(() => route.path, () => backToTop.value?.focus())
.VPSkipLink {
position: fixed;
top: 0.5rem;
left: 0.5rem;
inset-inline-start: 0.5rem;
padding: 0.5rem 1rem;
z-index: 999;
border-radius: 0.5rem;
@ -49,7 +49,7 @@ watch(() => route.path, () => backToTop.value?.focus())
@media (min-width: 80rem) {
.VPSkipLink {
top: 0.875rem;
left: 1rem;
inset-inline-start: 1rem;
}
}
</style>

@ -28,8 +28,7 @@
.check {
position: absolute;
top: 1px;
/*rtl:ignore*/
left: 1px;
inset-inline-start: 1px;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
@ -50,7 +49,7 @@
.icon :deep([class^='vpi-']) {
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
inset-inline-start: 0.1875rem;
width: 0.75rem;
height: 0.75rem;
color: var(--vp-c-text-2);

@ -51,7 +51,6 @@ watchPostEffect(() => {
}
.dark .VPSwitchAppearance :deep(.check) {
/*rtl:ignore*/
transform: translateX(1.125rem);
transform: translateX(calc(1.125rem * var(--vp-direction-multiplier)));
}
</style>

@ -22,11 +22,11 @@ withDefaults(defineProps<Props>(), {
</figure>
<div class="data">
<h1 class="name">
{{ member.name }}
<bdi>{{ member.name }}</bdi>
</h1>
<p v-if="member.title || member.org" class="affiliation">
<span v-if="member.title" class="title">
{{ member.title }}
<bdi>{{ member.title }}</bdi>
</span>
<span v-if="member.title && member.org" class="at"> @ </span>
<VPLink
@ -36,10 +36,10 @@ withDefaults(defineProps<Props>(), {
:href="member.orgLink"
no-icon
>
{{ member.org }}
<bdi>{{ member.org }}</bdi>
</VPLink>
</p>
<p v-if="member.desc" class="desc" v-html="member.desc" />
<p v-if="member.desc" class="desc"><bdi v-html="member.desc" /></p>
<div v-if="member.links" class="links">
<VPSocialLinks :links="member.links" :me="false" />
</div>
@ -223,7 +223,7 @@ withDefaults(defineProps<Props>(), {
}
.sp-icon {
margin-right: 0.5rem;
margin-inline-end: 0.5rem;
font-size: 1rem;
}
</style>

@ -3,11 +3,11 @@
<div class="title">
<div class="title-line" />
<h2 v-if="$slots.title" class="title-text">
<slot name="title" />
<bdi><slot name="title" /></bdi>
</h2>
</div>
<p v-if="$slots.lead" class="lead">
<slot name="lead" />
<bdi><slot name="lead" /></bdi>
</p>
<div v-if="$slots.members" class="members">
<slot name="members" />
@ -43,8 +43,7 @@
.title-line {
position: absolute;
top: 1rem;
left: 0;
width: 100%;
inset-inline: 0;
height: 1px;
background-color: var(--vp-c-divider);
}

@ -1,10 +1,10 @@
<template>
<div class="VPTeamPageTitle">
<h1 v-if="$slots.title" class="title">
<slot name="title" />
<bdi><slot name="title" /></bdi>
</h1>
<p v-if="$slots.lead" class="lead">
<slot name="lead" />
<bdi><slot name="lead" /></bdi>
</p>
</div>
</template>

@ -5,8 +5,7 @@
.vp-code-group .tabs {
position: relative;
display: flex;
margin-right: -1.5rem;
margin-left: -1.5rem;
margin-inline: -1.5rem;
padding: 0 0.75rem;
background-color: var(--vp-code-tab-bg);
overflow-x: auto;
@ -16,8 +15,7 @@
@media (min-width: 40rem) {
.vp-code-group .tabs {
margin-right: 0;
margin-left: 0;
margin-inline: 0;
border-radius: 0.5rem 0.5rem 0 0;
}
}
@ -44,9 +42,8 @@
.vp-code-group .tabs label::after {
position: absolute;
right: 0.5rem;
inset-inline: 0.5rem;
bottom: -1px;
left: 0.5rem;
z-index: 1;
height: 2px;
border-radius: 2px;
@ -71,8 +68,8 @@
.vp-block {
display: none;
margin-top: 0 !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-start-start-radius: 0 !important;
border-start-end-radius: 0 !important;
}
.vp-code-group div[class*='language-'].active,

@ -72,8 +72,8 @@
.vp-doc .header-anchor {
position: absolute;
top: 0;
left: 0;
margin-left: -0.87em;
inset-inline-start: 0;
margin-inline-start: -0.87em;
font-weight: 500;
user-select: none;
opacity: 0;
@ -130,8 +130,8 @@
.vp-doc blockquote {
margin: 1rem 0;
border-left: 2px solid var(--vp-c-divider);
padding-left: 1rem;
border-inline-start: 2px solid var(--vp-c-divider);
padding-inline-start: 1rem;
transition: border-color 0.5s;
color: var(--vp-c-text-2);
}
@ -166,7 +166,7 @@
.vp-doc ul,
.vp-doc ol {
padding-left: 1.25rem;
padding-inline-start: 1.25rem;
margin: 1rem 0;
}
@ -200,7 +200,8 @@
}
.vp-doc .task-list-item-checkbox {
margin: 0 0.25rem 0.125rem -1.25rem;
margin-block: 0 0.125rem;
margin-inline: -1.25rem 0.25rem;
vertical-align: middle;
accent-color: var(--vp-c-brand-1);
}
@ -246,7 +247,7 @@
}
.vp-doc th {
text-align: left;
text-align: start;
font-size: 0.875rem;
font-weight: 600;
color: var(--vp-c-text-2);
@ -323,6 +324,8 @@
border-radius: 0.25rem;
padding: 0.1875rem 0.375rem;
background-color: var(--vp-code-bg);
/* keep the written order of identifiers and punctuation inside rtl prose */
unicode-bidi: plaintext;
transition:
color 0.25s,
background-color 0.5s;
@ -343,6 +346,9 @@
font-size: 0.9em;
}
/* code-block wrappers carry dir="ltr" (see preWrapper.ts), so code, its
gutter and its controls keep their physical layout on rtl pages; the
logical properties in this section resolve to left/right there */
.vp-doc div[class*='language-'],
.vp-block {
position: relative;
@ -362,7 +368,9 @@
@media not all and (min-width: 40rem) {
.vp-doc li div[class*='language-'] {
border-radius: 0.5rem 0 0 0.5rem;
border-radius: 0;
border-start-start-radius: 0.5rem;
border-end-start-radius: 0.5rem;
}
}
@ -386,8 +394,7 @@
padding: 1.25rem 0;
background: transparent;
overflow-x: auto;
/*rtl:ignore*/
text-align: left;
text-align: start;
}
.vp-doc [class*='language-'] code {
@ -428,7 +435,7 @@
.vp-doc [class*='language-'] code .diff::before {
position: absolute;
left: 0.625rem;
inset-inline-start: 0.625rem;
}
.vp-doc [class*='language-'] .has-focused-lines .line:not(.has-focus) {
@ -471,19 +478,16 @@
}
.vp-doc div[class*='language-'].line-numbers-mode {
/*rtl:ignore*/
padding-left: 2rem;
padding-inline-start: 2rem;
}
.vp-doc .line-numbers-wrapper {
position: absolute;
top: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
inset-inline-start: 0;
z-index: 3;
/*rtl:ignore*/
border-right: 1px solid var(--vp-code-block-divider-color);
border-inline-end: 1px solid var(--vp-code-block-divider-color);
padding-top: 1.25rem;
width: 2rem;
text-align: center;
@ -497,12 +501,9 @@
}
.vp-doc [class*='language-'] > button.copy {
/*rtl:ignore*/
direction: ltr;
position: absolute;
top: 0.75rem;
/*rtl:ignore*/
right: 0.75rem;
inset-inline-end: 0.75rem;
z-index: 3;
border: 1px solid var(--vp-code-copy-code-border-color);
border-radius: 0.25rem;
@ -534,8 +535,8 @@
.vp-doc [class*='language-'] > button.copy.copied,
.vp-doc [class*='language-'] > button.copy:hover.copied {
/*rtl:ignore*/
border-radius: 0 0.25rem 0.25rem 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
background-color: var(--vp-code-copy-code-hover-bg);
background-image: var(--vp-icon-copied);
}
@ -544,16 +545,14 @@
.vp-doc [class*='language-'] > button.copy:hover.copied::before {
position: relative;
top: -1px;
/*rtl:ignore*/
transform: translateX(calc(-100% - 1px));
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--vp-code-copy-code-hover-border-color);
/*rtl:ignore*/
border-right: 0;
/*rtl:ignore*/
border-radius: 0.25rem 0 0 0.25rem;
border-inline-end: 0;
border-start-start-radius: 0.25rem;
border-end-start-radius: 0.25rem;
padding: 0 0.625rem;
width: fit-content;
height: 2.5rem;
@ -563,14 +562,15 @@
color: var(--vp-code-copy-code-active-text);
background-color: var(--vp-code-copy-code-hover-bg);
white-space: nowrap;
/* the wrapper is ltr; let the translated label pick its own direction */
unicode-bidi: plaintext;
content: attr(data-copied);
}
.vp-doc [class*='language-'] > span.lang {
position: absolute;
top: 0.125rem;
/*rtl:ignore*/
right: 0.5rem;
inset-inline-end: 0.5rem;
z-index: 2;
font-size: 0.75rem;
font-weight: 500;
@ -593,12 +593,12 @@
/* prettier-ignore */
:is(.vp-external-link-icon, .vp-doc a[href*='://'], .vp-doc a[target='_blank']):not(:is(.no-icon, svg a, :has(img, svg)))::after {
display: inline;
margin-left: 0.25rem;
padding-left: 0.6875rem;
margin-inline-start: 0.25rem;
padding-inline-start: 0.6875rem;
background: currentColor;
color: var(--vp-c-text-3);
flex-shrink: 0;
--icon: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M0 0h24v24H0V0z' fill='none' /%3E%3Cpath d='M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z' /%3E%3C/svg%3E");
--icon: var(--vp-icon-external-link);
-webkit-mask-image: var(--icon);
mask-image: var(--icon);
-webkit-mask-position: center;
@ -607,7 +607,6 @@
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
/*rtl:raw:transform: scaleX(-1);*/
vertical-align: middle;
font-size: 0.5625rem;
}

@ -59,7 +59,8 @@
}
.DocSearch-AskAiScreen-RelatedSources-Item-Link {
padding: 0.5rem 0.75rem 0.5rem 0.625rem;
padding-block: 0.5rem;
padding-inline: 0.625rem 0.75rem;
}
.DocSearch-AskAiScreen-RelatedSources-Item-Link svg {
@ -73,7 +74,7 @@
}
.DocSearch-Clear {
padding-right: 0.375rem;
padding-inline-end: 0.375rem;
}
.DocSearch-Commands-Key {

@ -42,19 +42,25 @@
}
.vpi-chevron-down,
.vpi-arrow-down {
/*rtl:ignore*/
transform: rotate(90deg);
}
.vpi-chevron-left,
.vpi-arrow-left {
/*rtl:ignore*/
transform: rotate(180deg);
}
.vpi-chevron-up,
.vpi-arrow-up {
/*rtl:ignore*/
transform: rotate(-90deg);
}
/* these point along the reading direction ("right" means forward), so they
mirror in rtl; the vertical variants and the key glyphs stay put */
.vpi-align-left,
.vpi-arrow-left,
.vpi-arrow-right,
.vpi-chevron-left,
.vpi-chevron-right {
scale: var(--vp-direction-multiplier) 1;
}
.vpi-square-pen {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z'/%3E%3C/g%3E%3C/svg%3E");
}
@ -91,9 +97,17 @@
.vpi-corner-down-left {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M20 4v7a4 4 0 0 1-4 4H4'/%3E%3Cpath d='m9 10l-5 5l5 5'/%3E%3C/g%3E%3C/svg%3E");
}
/* arrow-outward, drawn for ltr; an ltr island inside an rtl page resets it */
:root,
[dir='ltr'] {
--vp-icon-external-link: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M0 0h24v24H0V0z' fill='none' /%3E%3Cpath d='M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z' /%3E%3C/svg%3E");
}
:root {
/* clipboard */
--vp-icon-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3C/g%3E%3C/svg%3E");
/* clipboard-check */
--vp-icon-copied: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3Cpath d='m9 14l2 2l4-4'/%3E%3C/g%3E%3C/svg%3E");
}
[dir='rtl'] {
--vp-icon-external-link: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M0 0h24v24H0V0z' fill='none' /%3E%3Cpath d='M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z' transform='matrix(-1 0 0 1 24 0)' /%3E%3C/svg%3E");
}

@ -336,6 +336,22 @@
}
}
/**
* Direction
* -------------------------------------------------------------------------- */
/* 1 in ltr, -1 in rtl: mirrors the few transforms that have no logical form.
Keyed on the dir attribute so an ltr island inside an rtl page (a code
block) resets it at any depth. */
:root,
[dir='ltr'] {
--vp-direction-multiplier: 1;
}
[dir='rtl'] {
--vp-direction-multiplier: -1;
}
/**
* Layouts
* -------------------------------------------------------------------------- */

@ -42,7 +42,7 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
localeButton?.copiedText || options.codeCopyButton.copiedText
return (
`<div class="language-${lang}${active}">` +
`<div class="language-${lang}${active}" dir="ltr">` +
`<button title="${tooltipText}" data-copied="${copiedText}" class="copy"></button>` +
`<span class="lang">${label}</span>` +
fence(...args) +

@ -34,7 +34,12 @@ import { localSearchPlugin } from './plugins/localSearchPlugin'
import { rewritesPlugin } from './plugins/rewritesPlugin'
import { staticDataPlugin } from './plugins/staticDataPlugin'
import { webFontsPlugin } from './plugins/webFontsPlugin'
import { slash, type PageDataPayload } from './shared'
import {
isRelativeBase,
resolveSiteDataByRoute,
slash,
type PageDataPayload
} from './shared'
import { deserializeFunctions, serializeFunctions } from './utils/fnSerialize'
import { cacheAllGitTimestamps } from './utils/getGitTimestamp'
@ -307,9 +312,31 @@ export async function createVitePressPlugin(
if (url?.endsWith('.html')) {
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
// the shell of the requested page's locale, so the first paint
// already has its language and direction. req.url is the fallback
// page by now; the original request still names the actual one,
// served at the root when the base is relative
const base = isRelativeBase(site.base) ? '/' : site.base
const page = cleanUrl(req.originalUrl || url).slice(base.length)
let { lang, dir } = site
try {
const source =
decodeURI(page)
.replace(/(^|\/)$/, '$1index')
.replace(/\.html$/, '') + '.md'
// a bare locale root (/fa) counts as its directory
const localePath = /\.\w+$|\/$/.test(page) ? page : page + '/'
;({ lang, dir } = resolveSiteDataByRoute(
site,
localePath,
siteConfig.rewrites.inv[source] || source
))
} catch {
// malformed percent-encoding: keep the site-level values
}
let html = `\
<!DOCTYPE html>
<html>
<html lang="${lang}" dir="${dir}">
<head>
<title></title>
<meta charset="utf-8">

6
types/shared.d.ts vendored

@ -187,7 +187,7 @@ export interface SiteData<ThemeConfig = any> {
* The text direction (`dir` attribute) of the site.
* @default 'ltr'
*/
dir: string
dir: 'ltr' | 'rtl' | 'auto'
/**
* The title of the site.
* @default 'VitePress'
@ -295,7 +295,7 @@ export interface VitePressData<T = any> {
/**
* The text direction of the active locale.
*/
dir: Ref<string>
dir: Ref<'ltr' | 'rtl' | 'auto'>
/**
* The key of the active locale.
*/
@ -381,7 +381,7 @@ export interface LocaleSpecificConfig<ThemeConfig = any> {
/**
* The text direction of the locale.
*/
dir?: string
dir?: 'ltr' | 'rtl' | 'auto'
/**
* The title of the site in the locale.
*/

Loading…
Cancel
Save