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