From 304207c497b66126f60d47be1e7bdf9689181aed Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:42:23 +0530 Subject: [PATCH] feat(theme): native rtl support (#5437) Co-authored-by: Rami Yushuvaev Co-authored-by: ws-rush Co-authored-by: Claude Fable 5.1 --- __tests__/e2e/rtl/index.md | 53 +++++++ __tests__/e2e/rtl/index.test.ts | 146 ++++++++++++++++++ .../theme-default/logical-properties.test.ts | 84 ++++++++++ __tests__/unit/node/markdown/markdown.test.ts | 4 +- .../node/markdown/plugins/containers.test.ts | 8 +- docs/.postcssrc.json | 8 - docs/components/ModalDemo.vue | 5 +- docs/en/guide/i18n.md | 21 ++- docs/en/reference/default-theme-config.md | 3 +- docs/en/reference/frontmatter-config.md | 12 ++ docs/en/reference/runtime-api.md | 2 +- docs/en/reference/site-config.md | 14 ++ docs/package.json | 1 - docs/ru/components/ModalDemo.vue | 5 +- pnpm-lock.yaml | 32 ---- src/client/app/composables/copyCode.ts | 2 +- src/client/app/index.ts | 8 +- src/client/theme-default/NotFound.vue | 10 +- .../theme-default/components/VPBackdrop.vue | 7 +- .../theme-default/components/VPBadge.vue | 7 +- .../theme-default/components/VPContent.vue | 6 +- src/client/theme-default/components/VPDoc.vue | 6 +- .../components/VPDocAsideOutline.vue | 6 +- .../theme-default/components/VPDocFooter.vue | 10 +- .../components/VPDocFooterLastUpdated.vue | 4 +- .../components/VPDocOutlineItem.vue | 7 +- .../theme-default/components/VPFeature.vue | 12 +- .../theme-default/components/VPFlyout.vue | 6 +- .../theme-default/components/VPFooter.vue | 16 +- .../theme-default/components/VPHero.vue | 15 +- .../components/VPHomeContent.vue | 3 +- .../theme-default/components/VPLocalNav.vue | 7 +- .../components/VPLocalNavOutlineDropdown.vue | 11 +- .../components/VPLocalSearchBox.vue | 10 +- .../theme-default/components/VPMenuGroup.vue | 7 +- .../theme-default/components/VPMenuLink.vue | 6 +- src/client/theme-default/components/VPNav.vue | 3 +- .../components/VPNavAppearance.vue | 3 +- .../theme-default/components/VPNavBar.vue | 25 ++- .../components/VPNavBarExtra.vue | 2 +- .../components/VPNavBarHamburger.vue | 14 +- .../components/VPNavBarSearch.vue | 4 +- .../components/VPNavBarSearchButton.vue | 2 + .../components/VPNavBarTitle.vue | 4 +- .../theme-default/components/VPNavMenu.vue | 2 +- .../components/VPNavMenuGroup.vue | 6 +- .../components/VPNavMenuLink.vue | 2 +- .../theme-default/components/VPNavScreen.vue | 7 +- .../components/VPNavTranslations.vue | 12 +- .../theme-default/components/VPSidebar.vue | 11 +- .../components/VPSidebarItem.vue | 15 +- .../theme-default/components/VPSkipLink.vue | 4 +- .../theme-default/components/VPSwitch.vue | 5 +- .../components/VPSwitchAppearance.vue | 3 +- .../components/VPTeamMembersItem.vue | 10 +- .../components/VPTeamPageSection.vue | 7 +- .../components/VPTeamPageTitle.vue | 4 +- .../styles/components/vp-code-group.css | 13 +- .../styles/components/vp-doc.css | 67 ++++---- src/client/theme-default/styles/docsearch.css | 5 +- src/client/theme-default/styles/icons.css | 20 ++- src/client/theme-default/styles/vars.css | 16 ++ src/node/markdown/plugins/preWrapper.ts | 2 +- src/node/plugin.ts | 31 +++- types/shared.d.ts | 6 +- 65 files changed, 590 insertions(+), 279 deletions(-) create mode 100644 __tests__/e2e/rtl/index.md create mode 100644 __tests__/e2e/rtl/index.test.ts create mode 100644 __tests__/unit/client/theme-default/logical-properties.test.ts delete mode 100644 docs/.postcssrc.json diff --git a/__tests__/e2e/rtl/index.md b/__tests__/e2e/rtl/index.md new file mode 100644 index 000000000..6b6cdfe68 --- /dev/null +++ b/__tests__/e2e/rtl/index.md @@ -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. diff --git a/__tests__/e2e/rtl/index.test.ts b/__tests__/e2e/rtl/index.test.ts new file mode 100644 index 000000000..8225545bf --- /dev/null +++ b/__tests__/e2e/rtl/index.test.ts @@ -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('') + } + }) + + 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) + }) +}) diff --git a/__tests__/unit/client/theme-default/logical-properties.test.ts b/__tests__/unit/client/theme-default/logical-properties.test.ts new file mode 100644 index 000000000..cfdd3aac0 --- /dev/null +++ b/__tests__/unit/client/theme-default/logical-properties.test.ts @@ -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 = { + '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(/]*>([\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([]) + }) +}) diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts index af5d2fef6..9d7e2bc47 100644 --- a/__tests__/unit/node/markdown/markdown.test.ts +++ b/__tests__/unit/node/markdown/markdown.test.ts @@ -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('
') + expect(enabled).toContain('
') expect(enabled).toContain('class="copy"') const disabled = await render(src, { preWrapper: false }) - expect(disabled).not.toContain('
') + expect(disabled).not.toContain('
') expect(disabled).not.toContain('class="copy"') }) diff --git a/__tests__/unit/node/markdown/plugins/containers.test.ts b/__tests__/unit/node/markdown/plugins/containers.test.ts index 18e69025d..bd48c5814 100644 --- a/__tests__/unit/node/markdown/plugins/containers.test.ts +++ b/__tests__/unit/node/markdown/plugins/containers.test.ts @@ -91,7 +91,7 @@ describe('node/markdown/plugins/containers', () => {

content

Click me to toggle the code -
js
console.log('hi')
+      
js
console.log('hi')
       
" @@ -288,9 +288,9 @@ describe('node/markdown/plugins/containers', () => { ].join('\n') expect(await render(src)).toMatchInlineSnapshot(` "
-
js
const a = 1
+      
js
const a = 1
       
-
ts
const a: number = 1
+      
ts
const a: number = 1
       
" @@ -450,7 +450,7 @@ describe('node/markdown/plugins/containers (github alerts)', () => {
  • list item
-
js
const a = 1
+      
js
const a = 1
       
" diff --git a/docs/.postcssrc.json b/docs/.postcssrc.json deleted file mode 100644 index edc6a490f..000000000 --- a/docs/.postcssrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugins": { - "postcss-rtlcss": { - "ltrPrefix": ":where([dir=\"ltr\"])", - "rtlPrefix": ":where([dir=\"rtl\"])" - } - } -} diff --git a/docs/components/ModalDemo.vue b/docs/components/ModalDemo.vue index e5ba2e394..385d37dd1 100644 --- a/docs/components/ModalDemo.vue +++ b/docs/components/ModalDemo.vue @@ -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; diff --git a/docs/en/guide/i18n.md b/docs/en/guide/i18n.md index f7f285e60..53d94edef 100644 --- a/docs/en/guide/i18n.md +++ b/docs/en/guide/i18n.md @@ -141,6 +141,23 @@ watchEffect(() => { ``` -## RTL Support (Experimental) +## RTL Support -For RTL support, specify `dir: 'rtl'` in config and use some RTLCSS PostCSS plugin like , or . 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; +} +``` diff --git a/docs/en/reference/default-theme-config.md b/docs/en/reference/default-theme-config.md index 337603754..53d74d4ec 100644 --- a/docs/en/reference/default-theme-config.md +++ b/docs/en/reference/default-theme-config.md @@ -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. diff --git a/docs/en/reference/frontmatter-config.md b/docs/en/reference/frontmatter-config.md index 42f57f4a4..f36b57df9 100644 --- a/docs/en/reference/frontmatter-config.md +++ b/docs/en/reference/frontmatter-config.md @@ -83,6 +83,18 @@ type HeadConfig = | [string, Record, 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. diff --git a/docs/en/reference/runtime-api.md b/docs/en/reference/runtime-api.md index 86d9a20b1..bdaa49773 100644 --- a/docs/en/reference/runtime-api.md +++ b/docs/en/reference/runtime-api.md @@ -40,7 +40,7 @@ interface VitePressData { description: Ref lang: Ref isDark: Ref - dir: Ref + dir: Ref<'ltr' | 'rtl' | 'auto'> localeIndex: Ref /** * Current location hash diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index 1b0e2f883..762784cd5 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -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 `` 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` diff --git a/docs/package.json b/docs/package.json index 57d3a383e..e11aba76a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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" diff --git a/docs/ru/components/ModalDemo.vue b/docs/ru/components/ModalDemo.vue index ea9f7848c..b2a0484ed 100644 --- a/docs/ru/components/ModalDemo.vue +++ b/docs/ru/components/ModalDemo.vue @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 126b9b3df..2d45be79d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/client/app/composables/copyCode.ts b/src/client/app/composables/copyCode.ts index b686f0b90..d5f088482 100644 --- a/src/client/app/composables/copyCode.ts +++ b/src/client/app/composables/copyCode.ts @@ -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() diff --git a/src/client/app/index.ts b/src/client/app/index.ts index a15e06848..76b156a81 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -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 diff --git a/src/client/theme-default/NotFound.vue b/src/client/theme-default/NotFound.vue index 5805ac80a..b6ecaba39 100644 --- a/src/client/theme-default/NotFound.vue +++ b/src/client/theme-default/NotFound.vue @@ -11,13 +11,15 @@ const { currentLang } = useLangs() @@ -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; diff --git a/src/client/theme-default/components/VPNav.vue b/src/client/theme-default/components/VPNav.vue index 051a30094..e70f3d744 100644 --- a/src/client/theme-default/components/VPNav.vue +++ b/src/client/theme-default/components/VPNav.vue @@ -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; diff --git a/src/client/theme-default/components/VPNavAppearance.vue b/src/client/theme-default/components/VPNavAppearance.vue index 6873e4008..1f51667cd 100644 --- a/src/client/theme-default/components/VPNavAppearance.vue +++ b/src/client/theme-default/components/VPNavAppearance.vue @@ -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); } diff --git a/src/client/theme-default/components/VPNavBar.vue b/src/client/theme-default/components/VPNavBar.vue index a0a97633c..30a5934ee 100644 --- a/src/client/theme-default/components/VPNavBar.vue +++ b/src/client/theme-default/components/VPNavBar.vue @@ -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); diff --git a/src/client/theme-default/components/VPNavBarExtra.vue b/src/client/theme-default/components/VPNavBarExtra.vue index dfaa4f9d1..f7ac379fb 100644 --- a/src/client/theme-default/components/VPNavBarExtra.vue +++ b/src/client/theme-default/components/VPNavBarExtra.vue @@ -89,7 +89,7 @@ const hasContent = computed( diff --git a/src/client/theme-default/components/VPNavBarSearch.vue b/src/client/theme-default/components/VPNavBarSearch.vue index 1fe948916..5232e3e91 100644 --- a/src/client/theme-default/components/VPNavBarSearch.vue +++ b/src/client/theme-default/components/VPNavBarSearch.vue @@ -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; } } diff --git a/src/client/theme-default/components/VPNavBarSearchButton.vue b/src/client/theme-default/components/VPNavBarSearchButton.vue index 106fbd558..e9830adcb 100644 --- a/src/client/theme-default/components/VPNavBarSearchButton.vue +++ b/src/client/theme-default/components/VPNavBarSearchButton.vue @@ -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; diff --git a/src/client/theme-default/components/VPNavBarTitle.vue b/src/client/theme-default/components/VPNavBarTitle.vue index 35ce10677..b3ccadfbd 100644 --- a/src/client/theme-default/components/VPNavBarTitle.vue +++ b/src/client/theme-default/components/VPNavBarTitle.vue @@ -49,7 +49,7 @@ const textTitle = computed(() => { >