mirror of https://github.com/vuejs/vitepress
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
parent
5f69cc9d90
commit
304207c497
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"plugins": {
|
||||
"postcss-rtlcss": {
|
||||
"ltrPrefix": ":where([dir=\"ltr\"])",
|
||||
"rtlPrefix": ":where([dir=\"rtl\"])"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue