feat: add i18n feature (#1339)

fix #291
fix #628
fix #631
fix #902
fix #955
fix #1253
fix #1381

Co-authored-by: Hiroki Okada <hirokio@tutanota.com>
Co-authored-by: Sadegh Barati <sadeghbaratiwork@gmail.com>
pull/1811/head
Divyansh Singh 1 year ago committed by GitHub
parent 471f00a68d
commit 8de2f4499d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -84,7 +84,8 @@ function sidebarGuide() {
{ text: 'What is VitePress?', link: '/guide/what-is-vitepress' },
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Configuration', link: '/guide/configuration' },
{ text: 'Deploying', link: '/guide/deploying' }
{ text: 'Deploying', link: '/guide/deploying' },
{ text: 'Internationalization', link: '/guide/i18n' }
]
},
{

@ -19,6 +19,12 @@ export default {
Here it describes the settings for the VitePress default theme. If you're using a custom theme created by others, these settings may not have any effect, or might behave differently.
## i18nRouting
- Type: `boolean`
Changing locale to say `zh` will change the URL from `/foo` (or `/en/foo/`) to `/zh/foo`. You can disable this behavior by setting `themeConfig.i18nRouting` to `false`.
## logo
- Type: `ThemeableImage`

@ -11,16 +11,17 @@ Methods that start with `use*` indicates that it is a [Vue 3 Composition API](ht
Returns page-specific data. The returned object has the following type:
```ts
interface VitePressData {
site: Ref<SiteData>
interface VitePressData<T = any> {
site: Ref<SiteData<T>>
page: Ref<PageData>
theme: Ref<any> // themeConfig from .vitepress/config.js
theme: Ref<T> // themeConfig from .vitepress/config.js
frontmatter: Ref<PageData['frontmatter']>
lang: Ref<string>
title: Ref<string>
description: Ref<string>
localePath: Ref<string>
lang: Ref<string>
isDark: Ref<boolean>
dir: Ref<string>
localeIndex: Ref<string>
}
```

@ -0,0 +1,99 @@
# Internationalization
To use the built-in i18n features, one needs to create a directory structure as follows:
```
docs/
├─ es/
│ ├─ foo.md
├─ fr/
│ ├─ foo.md
├─ foo.md
```
Then in `docs/.vitepress/config.ts`:
```ts
import { defineConfig } from 'vitepress'
export default defineConfig({
// shared properties and other top-level stuff...
locales: {
root: {
label: 'English',
lang: 'en'
},
fr: {
label: 'French',
lang: 'fr', // optional, will be added as `lang` attribute on `html` tag
link: '/fr/guide' // default /fr/ -- shows on navbar translations menu, can be external
// other locale specific properties...
}
}
})
```
The following properties can be overridden for each locale (including root):
```ts
interface LocaleSpecificConfig<ThemeConfig = any> {
lang?: string
dir?: string
title?: string
titleTemplate?: string | boolean
description?: string
head?: HeadConfig[] // will be merged with existing head entries, duplicate meta tags are automatically removed
themeConfig?: ThemeConfig // will be shallow merged, common stuff can be put in top-level themeConfig entry
}
```
Refer [`DefaultTheme.Config`](https://github.com/vuejs/vitepress/blob/main/types/default-theme.d.ts) interface for details on customizing the placeholder texts of the default theme. Don't override `themeConfig.algolia` or `themeConfig.carbonAds` at locale-level. Refer [Algolia docs](./theme-search#i18n) for using multilingual search.
**Pro tip:** Config file can be stored at `docs/.vitepress/config/index.ts` too. It might help you organize stuff by creating a configuration file per locale and then merge and export them from `index.ts`.
## Separate directory for each locale
The following is a perfectly fine structure:
```
docs/
├─ en/
│ ├─ foo.md
├─ es/
│ ├─ foo.md
├─ fr/
├─ foo.md
```
However, VitePress won't redirect `/` to `/en/` by default. You'll need to configure your server for that. For example, on Netlify, you can add a `docs/public/_redirects` file like this:
```
/* /es/:splat 302 Language=es
/* /fr/:splat 302 Language=fr
/* /en/:splat 302
```
**Pro tip:** If using the above approach, you can use `nf_lang` cookie to persist user's language choice. A very basic way to do this is register a watcher inside the [setup](./theme-introduction#using-a-custom-theme) function of custom theme:
```ts
// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
export default {
...DefaultTheme,
setup() {
const { lang } = useData()
watchEffect(() => {
if (inBrowser) {
document.cookie = `nf_lang=${lang.value}; expires=Mon, 1 Jan 2024 00:00:00 UTC; path=/`
}
})
}
}
```
## RTL Support (Experimental)
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.

@ -1,3 +1,85 @@
# Search
Documentation coming soon...
VitePress supports searching your docs site using [Algolia DocSearch](https://docsearch.algolia.com/docs/what-is-docsearch). Refer their getting started guide. In your `.vitepress/config.ts` you'll need to provide at least the following to make it work:
```ts
import { defineConfig } from 'vitepress'
export default defineConfig({
themeConfig: {
algolia: {
appId: '...',
apiKey: '...',
indexName: '...'
}
}
})
```
If you are not eligible for DocSearch, you might wanna use some community plugins like <https://github.com/emersonbottero/vitepress-plugin-search> or explore some custom solutions on [this GitHub thread](https://github.com/vuejs/vitepress/issues/670).
## i18n
You can use a config like this to use multilingual search:
```ts
import { defineConfig } from 'vitepress'
export default defineConfig({
// ...
themeConfig: {
// ...
algolia: {
appId: '...',
apiKey: '...',
indexName: '...',
locales: {
zh: {
placeholder: '搜索文档',
translations: {
button: {
buttonText: '搜索文档',
buttonAriaLabel: '搜索文档'
},
modal: {
searchBox: {
resetButtonTitle: '清除查询条件',
resetButtonAriaLabel: '清除查询条件',
cancelButtonText: '取消',
cancelButtonAriaLabel: '取消'
},
startScreen: {
recentSearchesTitle: '搜索历史',
noRecentSearchesText: '没有搜索历史',
saveRecentSearchButtonTitle: '保存至搜索历史',
removeRecentSearchButtonTitle: '从搜索历史中移除',
favoriteSearchesTitle: '收藏',
removeFavoriteSearchButtonTitle: '从收藏中移除'
},
errorScreen: {
titleText: '无法获取结果',
helpText: '你可能需要检查你的网络连接'
},
footer: {
selectText: '选择',
navigateText: '切换',
closeText: '关闭',
searchByText: '搜索提供者'
},
noResultsScreen: {
noResultsText: '无法找到相关结果',
suggestedQueryText: '你可以尝试查询',
reportMissingResultsText: '你认为该查询应该有结果?',
reportMissingResultsLinkText: '点击反馈'
}
}
}
}
}
}
}
})
```
[These options](https://github.com/vuejs/vitepress/blob/main/types/docsearch.d.ts) can be overridden. Refer official Algolia docs to learn more about them.

@ -2,7 +2,7 @@ import { promises as fs } from 'fs'
import { builtinModules, createRequire } from 'module'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
import { RollupOptions, defineConfig } from 'rollup'
import { type RollupOptions, defineConfig } from 'rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import esbuild from 'rollup-plugin-esbuild'

@ -15,7 +15,6 @@ import {
resolveSiteDataByRoute,
createTitle
} from '../shared.js'
import { withBase } from './utils.js'
export const dataSymbol: InjectionKey<VitePressData> = Symbol()
@ -27,8 +26,9 @@ export interface VitePressData<T = any> {
title: Ref<string>
description: Ref<string>
lang: Ref<string>
localePath: Ref<string>
isDark: Ref<boolean>
dir: Ref<string>
localeIndex: Ref<string>
}
// site data is a singleton
@ -48,7 +48,7 @@ if (import.meta.hot) {
// per-app data
export function initData(route: Route): VitePressData {
const site = computed(() =>
resolveSiteDataByRoute(siteDataRef.value, route.path)
resolveSiteDataByRoute(siteDataRef.value, route.data.relativePath)
)
return {
@ -57,13 +57,8 @@ export function initData(route: Route): VitePressData {
page: computed(() => route.data),
frontmatter: computed(() => route.data.frontmatter),
lang: computed(() => site.value.lang),
localePath: computed(() => {
const { langs, lang } = site.value
const path = Object.keys(langs).find(
(langPath) => langs[langPath].lang === lang
)
return withBase(path || '/')
}),
dir: computed(() => site.value.dir),
localeIndex: computed(() => site.value.localeIndex || 'root'),
title: computed(() => {
return createTitle(site.value, route.data)
}),

@ -5,7 +5,7 @@ import {
defineComponent,
h,
onMounted,
watch
watchEffect
} from 'vue'
import Theme from '@theme/index'
import { inBrowser, pathToFile } from './utils.js'
@ -28,13 +28,10 @@ const VitePressApp = defineComponent({
// change the language on the HTML element based on the current lang
onMounted(() => {
watch(
() => site.value.lang,
(lang: string) => {
document.documentElement.lang = lang
},
{ immediate: true }
)
watchEffect(() => {
document.documentElement.lang = site.value.lang
document.documentElement.dir = site.value.dir
})
})
if (import.meta.env.PROD) {

@ -1,7 +1,7 @@
import { siteDataRef } from './data.js'
import { inBrowser, EXTERNAL_URL_RE, sanitizeFileName } from '../shared.js'
export { inBrowser }
export { inBrowser } from '../shared.js'
/**
* Join two paths by resolving the slash collision.
@ -11,7 +11,7 @@ export function joinPath(base: string, path: string): string {
}
export function withBase(path: string) {
return EXTERNAL_URL_RE.test(path)
return EXTERNAL_URL_RE.test(path) || path.startsWith('.')
? path
: joinPath(siteDataRef.value.base, path)
}

@ -11,8 +11,7 @@ export type {
PageData,
SiteData,
HeadConfig,
Header,
LocaleConfig
Header
} from '../../types/shared.js'
// composables

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, provide, useSlots, watch } from 'vue'
import { useData, useRoute } from 'vitepress'
import { useRoute } from 'vitepress'
import { useData } from './composables/data.js'
import { useSidebar, useCloseSidebarOnEscape } from './composables/sidebar.js'
import VPSkipLink from './components/VPSkipLink.vue'
import VPBackdrop from './components/VPBackdrop.vue'

@ -1,7 +1,23 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { onMounted, ref } from 'vue'
import { withBase } from 'vitepress'
import { useData } from './composables/data.js'
import { useLangs } from './composables/langs.js'
const { site } = useData()
const { localeLinks } = useLangs({ removeCurrent: false })
const root = ref('/')
onMounted(() => {
const path = window.location.pathname
.replace(site.value.base, '')
.replace(/(^.*?\/).*$/, '/$1')
if (localeLinks.value.length) {
root.value =
localeLinks.value.find(({ link }) => link.startsWith(path))?.link ||
localeLinks.value[0].link
}
})
</script>
<template>
@ -14,7 +30,7 @@ const { site } = useData()
</blockquote>
<div class="action">
<a class="link" :href="site.base" aria-label="go to home">
<a class="link" :href="withBase(root)" aria-label="go to home">
Take me home
</a>
</div>
@ -51,7 +67,7 @@ const { site } = useData()
margin: 24px auto 18px;
width: 64px;
height: 1px;
background-color: var(--vp-c-divider)
background-color: var(--vp-c-divider);
}
.quote {
@ -74,7 +90,7 @@ const { site } = useData()
font-size: 14px;
font-weight: 500;
color: var(--vp-c-brand);
transition: border-color 0.25s, color .25s;
transition: border-color 0.25s, color 0.25s;
}
.link:hover {

@ -1,40 +1,47 @@
<script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme'
import docsearch from '@docsearch/js'
import { onMounted } from 'vue'
import { useRouter, useRoute, useData } from 'vitepress'
import { onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vitepress'
import { useData } from '../composables/data.js'
const props = defineProps<{
algolia: DefaultTheme.AlgoliaSearchOptions
}>()
const router = useRouter()
const route = useRoute()
const { theme, site } = useData()
onMounted(() => {
initialize(theme.value.algolia)
setTimeout(poll, 16)
})
function poll() {
// programmatically open the search box after initialize
const e = new Event('keydown') as any
const { site, localeIndex, lang } = useData()
e.key = 'k'
e.metaKey = true
const docsearch$ = docsearch.default ?? docsearch
type DocSearchProps = Parameters<typeof docsearch$>[0]
window.dispatchEvent(e)
onMounted(update)
watch(localeIndex, update)
setTimeout(() => {
if (!document.querySelector('.DocSearch-Modal')) {
poll()
function update() {
const options = {
...props.algolia,
...props.algolia.locales?.[localeIndex.value]
}
const rawFacetFilters = options.searchParameters?.facetFilters ?? []
const facetFilters = [
...(Array.isArray(rawFacetFilters)
? rawFacetFilters
: [rawFacetFilters]
).filter((f) => !f.startsWith('lang:')),
`lang:${lang.value}`
]
initialize({
...options,
searchParameters: {
...options.searchParameters,
facetFilters
}
}, 16)
})
}
const docsearch$ = docsearch.default ?? docsearch
type DocSearchProps = Parameters<typeof docsearch$>[0]
function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
// note: multi-lang search support is removed since the theme
// doesn't support multiple locales as of now.
const options = Object.assign<{}, {}, DocSearchProps>({}, userOptions, {
container: '#docsearch',

@ -14,8 +14,10 @@ defineProps<{
.VPBackdrop {
position: fixed;
top: 0;
/*rtl:ignore*/
right: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-backdrop);
background: var(--vp-backdrop-bg-color);

@ -1,10 +1,16 @@
<script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme'
import { ref, watch, onMounted } from 'vue'
import { useData } from 'vitepress'
import { useAside } from '../composables/aside.js'
import { useData } from '../composables/data.js'
const { page } = useData()
const props = defineProps<{
carbonAds: DefaultTheme.CarbonAdsOptions
}>()
const carbonOptions = props.carbonAds
const { theme, page } = useData()
const carbonOptions = theme.value.carbonAds
const { isAsideEnabled } = useAside()
const container = ref()

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useRoute, useData } from 'vitepress'
import { useRoute } from 'vitepress'
import { useData } from '../composables/data.js'
import { useSidebar } from '../composables/sidebar.js'
import VPPage from './VPPage.vue'
import VPHome from './VPHome.vue'

@ -1,9 +1,13 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { defineAsyncComponent } from 'vue'
import { useData } from '../composables/data.js'
import VPDocAsideOutline from './VPDocAsideOutline.vue'
import VPDocAsideCarbonAds from './VPDocAsideCarbonAds.vue'
const { theme } = useData()
const VPCarbonAds = __CARBON__
? defineAsyncComponent(() => import('./VPCarbonAds.vue'))
: () => null
</script>
<template>
@ -17,7 +21,7 @@ const { theme } = useData()
<div class="spacer" />
<slot name="aside-ads-before" />
<VPDocAsideCarbonAds v-if="theme.carbonAds" />
<VPCarbonAds v-if="theme.carbonAds" :carbonAds="theme.carbonAds" />
<slot name="aside-ads-after" />
<slot name="aside-bottom" />

@ -1,13 +0,0 @@
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const VPCarbonAds = __CARBON__
? defineAsyncComponent(() => import('./VPCarbonAds.vue'))
: () => null
</script>
<template>
<div class="VPDocAsideCarbonAds">
<VPCarbonAds />
</div>
</template>

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject, ref, type Ref } from 'vue'
import { useData } from '../composables/data.js'
import {
getHeaders,
useActiveAnchor,
@ -43,7 +43,13 @@ function handleClick({ target: el }: Event) {
<div class="outline-marker" ref="marker" />
<div class="outline-title">
{{ theme.outlineTitle || 'On this page' }}
{{
(typeof theme.outline === 'object' &&
!Array.isArray(theme.outline) &&
theme.outline.label) ||
theme.outlineTitle ||
'On this page'
}}
</div>
<nav aria-labelledby="doc-outline-aria-label">

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { normalizeLink } from '../support/utils.js'
import { useEditLink } from '../composables/edit-link.js'
import { usePrevNext } from '../composables/prev-next.js'
@ -42,13 +42,13 @@ const showFooter = computed(() => {
<div v-if="control.prev || control.next" class="prev-next">
<div class="pager">
<a v-if="control.prev" class="pager-link prev" :href="normalizeLink(control.prev.link)">
<span class="desc" v-html="theme.docFooter?.prev ?? 'Previous page'"></span>
<span class="desc" v-html="theme.docFooter?.prev || 'Previous page'"></span>
<span class="title" v-html="control.prev.text"></span>
</a>
</div>
<div class="pager" :class="{ 'has-prev': control.prev }">
<a v-if="control.next" class="pager-link next" :href="normalizeLink(control.next.link)">
<span class="desc" v-html="theme.docFooter?.next ?? 'Next page'"></span>
<span class="desc" v-html="theme.docFooter?.next || 'Next page'"></span>
<span class="title" v-html="control.next.text"></span>
</a>
</div>

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, watchEffect, onMounted } from 'vue'
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
const { theme, page } = useData()
@ -20,7 +20,7 @@ onMounted(() => {
<template>
<p class="VPLastUpdated">
{{ theme.lastUpdatedText ?? 'Last updated' }}:
{{ theme.lastUpdatedText || 'Last updated' }}:
<time :datetime="isoDatetime">{{ datetime }}</time>
</p>
</template>

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { useSidebar } from '../composables/sidebar.js'
const { theme } = useData()

@ -267,6 +267,7 @@ const heroImageSlotExists = inject('hero-image-slot-exists') as Ref<boolean>
align-items: center;
width: 100%;
height: 100%;
/*rtl:ignore*/
transform: translate(-32px, -32px);
}
}
@ -274,12 +275,14 @@ const heroImageSlotExists = inject('hero-image-slot-exists') as Ref<boolean>
.image-bg {
position: absolute;
top: 50%;
/*rtl:ignore*/
left: 50%;
border-radius: 50%;
width: 192px;
height: 192px;
background-image: var(--vp-home-hero-image-background-image);
filter: var(--vp-home-hero-image-filter);
/*rtl:ignore*/
transform: translate(-50%, -50%);
}
@ -300,8 +303,10 @@ const heroImageSlotExists = inject('hero-image-slot-exists') as Ref<boolean>
:deep(.image-src) {
position: absolute;
top: 50%;
/*rtl:ignore*/
left: 50%;
max-width: 192px;
/*rtl:ignore*/
transform: translate(-50%, -50%);
}

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPFeatures from './VPFeatures.vue'
const { frontmatter: fm } = useData()

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPHero from './VPHero.vue'
const { frontmatter: fm } = useData()

@ -1,4 +1,5 @@
<script lang="ts" setup>
import { useData } from '../composables/data.js'
import { useSidebar } from '../composables/sidebar.js'
import VPIconAlignLeft from './icons/VPIconAlignLeft.vue'
@ -10,6 +11,7 @@ defineEmits<{
(e: 'open-menu'): void
}>()
const { theme } = useData()
const { hasSidebar } = useSidebar()
function scrollToTop() {
@ -26,11 +28,13 @@ function scrollToTop() {
@click="$emit('open-menu')"
>
<VPIconAlignLeft class="menu-icon" />
<span class="menu-text">Menu</span>
<span class="menu-text">
{{ theme.sidebarMenuLabel || 'Menu' }}
</span>
</button>
<a class="top-link" href="#" @click="scrollToTop">
Return to top
{{ theme.returnToTopLabel || 'Return to top' }}
</a>
</div>
</template>
@ -39,6 +43,7 @@ function scrollToTop() {
.VPLocalNav {
position: sticky;
top: 0;
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-local-nav);
display: flex;

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { isActive } from '../support/utils.js'
import VPLink from './VPLink.vue'

@ -38,6 +38,7 @@ const classes = computed(() => ({
.VPNav {
position: relative;
top: var(--vp-layout-top-height, 0px);
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-nav);
width: 100%;

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site } = useData()

@ -1,29 +1,38 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { computed } from 'vue'
import VPFlyout from './VPFlyout.vue'
import VPMenuLink from './VPMenuLink.vue'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
import VPSocialLinks from './VPSocialLinks.vue'
import { computed } from 'vue'
import { useData } from '../composables/data.js'
import { useLangs } from '../composables/langs.js'
const { site, theme } = useData()
const { localeLinks, currentLang } = useLangs({ correspondingLink: true })
const hasExtraContent = computed(() => theme.value.localeLinks || site.value.appearance || theme.value.socialLinks)
const hasExtraContent = computed(
() =>
(localeLinks.value.length && currentLang.value.label) ||
site.value.appearance ||
theme.value.socialLinks
)
</script>
<template>
<VPFlyout v-if="hasExtraContent" class="VPNavBarExtra" label="extra navigation">
<div v-if="theme.localeLinks" class="group">
<p class="trans-title">{{ theme.localeLinks.text }}</p>
<div v-if="localeLinks.length && currentLang.label" class="group">
<p class="trans-title">{{ currentLang.label }}</p>
<template v-for="locale in theme.localeLinks.items" :key="locale.link">
<template v-for="locale in localeLinks" :key="locale.link">
<VPMenuLink :item="locale" />
</template>
</div>
<div v-if="site.appearance" class="group">
<div class="item appearance">
<p class="label">Appearance</p>
<p class="label">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<div class="appearance-action">
<VPSwitchAppearance />
</div>

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPNavBarMenuLink from './VPNavBarMenuLink.vue'
import VPNavBarMenuGroup from './VPNavBarMenuGroup.vue'

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { useData } from '../composables/data.js'
import { isActive } from '../support/utils.js'
import VPFlyout from './VPFlyout.vue'

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { isActive } from '../support/utils.js'
import VPLink from './VPLink.vue'

@ -1,13 +1,19 @@
<script lang="ts" setup>
import '@docsearch/css'
import { defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
import { useData } from 'vitepress'
import {
computed,
defineAsyncComponent,
onMounted,
onUnmounted,
ref
} from 'vue'
import { useData } from '../composables/data.js'
const VPAlgoliaSearchBox = __ALGOLIA__
? defineAsyncComponent(() => import('./VPAlgoliaSearchBox.vue'))
: () => null
const { theme } = useData()
const { theme, localeIndex } = useData()
// to avoid loading the docsearch js upfront (which is more than 1/3 of the
// payload), we delay initializing it until the user has actually clicked or
@ -15,6 +21,13 @@ const { theme } = useData()
const loaded = ref(false)
const metaKey = ref(`'Meta'`)
const buttonText = computed(
() =>
theme.value.algolia?.locales?.[localeIndex.value]?.translations?.button
?.buttonText ||
theme.value.algolia?.translations?.button?.buttonText ||
'Search'
)
onMounted(() => {
if (!theme.value.algolia) {
@ -46,13 +59,30 @@ onMounted(() => {
function load() {
if (!loaded.value) {
loaded.value = true
setTimeout(poll, 16)
}
}
function poll() {
// programmatically open the search box after initialize
const e = new Event('keydown') as any
e.key = 'k'
e.metaKey = true
window.dispatchEvent(e)
setTimeout(() => {
if (!document.querySelector('.DocSearch-Modal')) {
poll()
}
}, 16)
}
</script>
<template>
<div v-if="theme.algolia" class="VPNavBarSearch">
<VPAlgoliaSearchBox v-if="loaded" />
<VPAlgoliaSearchBox v-if="loaded" :algolia="theme.algolia" />
<div v-else id="docsearch" @click="load">
<button
@ -76,7 +106,7 @@ function load() {
stroke-linejoin="round"
/>
</svg>
<span class="DocSearch-Button-Placeholder">{{ theme.algolia?.buttonText || 'Search' }}</span>
<span class="DocSearch-Button-Placeholder">{{ buttonText }}</span>
</span>
<span class="DocSearch-Button-Keys">
<kbd class="DocSearch-Button-Key"></kbd>
@ -219,6 +249,8 @@ function load() {
}
.DocSearch-Button .DocSearch-Button-Keys {
/*rtl:ignore*/
direction: ltr;
display: none;
min-width: auto;
}
@ -234,9 +266,11 @@ function load() {
display: block;
margin: 2px 0 0 0;
border: 1px solid var(--vp-c-divider);
/*rtl:begin:ignore*/
border-right: none;
border-radius: 4px 0 0 4px;
padding-left: 6px;
/*rtl:end:ignore*/
min-width: 0;
width: auto;
height: 22px;
@ -248,11 +282,13 @@ function load() {
}
.DocSearch-Button .DocSearch-Button-Key + .DocSearch-Button-Key {
/*rtl:begin:ignore*/
border-right: 1px solid var(--vp-c-divider);
border-left: none;
border-radius: 0 4px 4px 0;
padding-left: 2px;
padding-right: 6px;
/*rtl:end:ignore*/
}
.DocSearch-Button .DocSearch-Button-Key:first-child {

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPSocialLinks from './VPSocialLinks.vue'
const { theme } = useData()

@ -1,17 +1,20 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { useSidebar } from '../composables/sidebar.js'
import { useLangs } from '../composables/langs.js'
import { normalizeLink } from '../support/utils.js'
import VPImage from './VPImage.vue'
const { site, theme } = useData()
const { hasSidebar } = useSidebar()
const { currentLang } = useLangs()
</script>
<template>
<div class="VPNavBarTitle" :class="{ 'has-sidebar': hasSidebar }">
<a class="title" :href="site.base">
<a class="title" :href="normalizeLink(currentLang.link)">
<slot name="nav-bar-title-before" />
<VPImage class="logo" :image="theme.logo" />
<VPImage v-if="theme.logo" class="logo" :image="theme.logo" />
<template v-if="theme.siteTitle">{{ theme.siteTitle }}</template>
<template v-else-if="theme.siteTitle === undefined">{{ site.title }}</template>
<slot name="nav-bar-title-after" />

@ -1,22 +1,22 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import VPIconLanguages from './icons/VPIconLanguages.vue'
import VPFlyout from './VPFlyout.vue'
import VPMenuLink from './VPMenuLink.vue'
import { useLangs } from '../composables/langs.js'
const { theme } = useData()
const { localeLinks, currentLang } = useLangs({ correspondingLink: true })
</script>
<template>
<VPFlyout
v-if="theme.localeLinks"
v-if="localeLinks.length && currentLang.label"
class="VPNavBarTranslations"
:icon="VPIconLanguages"
>
<div class="items">
<p class="title">{{ theme.localeLinks.text }}</p>
<p class="title">{{ currentLang.label }}</p>
<template v-for="locale in theme.localeLinks.items" :key="locale.link">
<template v-for="locale in localeLinks" :key="locale.link">
<VPMenuLink :item="locale" />
</template>
</div>

@ -44,8 +44,10 @@ function unlockBodyScroll() {
.VPNavScreen {
position: fixed;
top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 1px);
/*rtl:ignore*/
right: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
padding: 0 32px;
width: 100%;

@ -1,13 +1,15 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site } = useData()
const { site, theme } = useData()
</script>
<template>
<div v-if="site.appearance" class="VPNavScreenAppearance">
<p class="text">Appearance</p>
<p class="text">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<VPSwitchAppearance />
</div>
</template>

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPNavScreenMenuLink from './VPNavScreenMenuLink.vue'
import VPNavScreenMenuGroup from './VPNavScreenMenuGroup.vue'

@ -79,6 +79,7 @@ function toggle() {
}
.VPNavScreenMenuGroup.open .button-icon {
/*rtl:ignore*/
transform: rotate(45deg);
}

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import VPSocialLinks from './VPSocialLinks.vue'
const { theme } = useData()

@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useData } from 'vitepress'
import VPIconChevronDown from './icons/VPIconChevronDown.vue'
import VPIconLanguages from './icons/VPIconLanguages.vue'
import { useLangs } from '../composables/langs.js'
import VPLink from './VPLink.vue'
const { theme } = useData()
const { localeLinks, currentLang } = useLangs({ correspondingLink: true })
const isOpen = ref(false)
function toggle() {
@ -14,16 +14,20 @@ function toggle() {
</script>
<template>
<div v-if="theme.localeLinks" class="VPNavScreenTranslations" :class="{ open: isOpen }">
<div
v-if="localeLinks.length && currentLang.label"
class="VPNavScreenTranslations"
:class="{ open: isOpen }"
>
<button class="title" @click="toggle">
<VPIconLanguages class="icon lang" />
{{ theme.localeLinks.text }}
{{ currentLang.label }}
<VPIconChevronDown class="icon chevron" />
</button>
<ul class="list">
<li v-for="locale in theme.localeLinks.items" :key="locale.link" class="item">
<a class="link" :href="locale.link">{{ locale.text }}</a>
<li v-for="locale in localeLinks" :key="locale.link" class="item">
<VPLink class="link" :href="locale.link">{{ locale.text }}</VPLink>
</li>
</ul>
</div>

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { ref, watchEffect } from 'vue'
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { isActive } from '../support/utils.js'
import VPIconPlusSquare from './icons/VPIconPlusSquare.vue'
import VPIconMinusSquare from './icons/VPIconMinusSquare.vue'

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { type Ref, computed, inject, ref, watchEffect } from 'vue'
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { useSidebar } from '../composables/sidebar.js'
import { isActive } from '../support/utils.js'
import VPLink from './VPLink.vue'

@ -28,6 +28,7 @@
.check {
position: absolute;
top: 1px;
/*rtl:ignore*/
left: 1px;
width: 18px;
height: 18px;

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue'
import { useData } from 'vitepress'
import { useData } from '../composables/data.js'
import { APPEARANCE_KEY } from '../../shared.js'
import VPSwitch from './VPSwitch.vue'
import VPIconSun from './icons/VPIconSun.vue'
@ -104,6 +104,7 @@ watch(checked, (newIsDark) => {
}
.dark .VPSwitchAppearance :deep(.check) {
/*rtl:ignore*/
transform: translateX(18px);
}
</style>

@ -0,0 +1,4 @@
import { useData as useData$ } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
export const useData: typeof useData$<DefaultTheme.Config> = useData$

@ -1,11 +1,11 @@
import { computed } from 'vue'
import { useData } from 'vitepress'
import { useData } from './data.js'
export function useEditLink() {
const { theme, page } = useData()
return computed(() => {
const { text = 'Edit this page', pattern } = theme.value.editLink || {}
const { text = 'Edit this page', pattern = '' } = theme.value.editLink || {}
const { relativePath } = page.value
const url = pattern.replace(/:path/g, relativePath)

@ -0,0 +1,50 @@
import { computed } from 'vue'
import { useData } from './data.js'
import { ensureStartingSlash } from '../support/utils.js'
export function useLangs({
removeCurrent = true,
correspondingLink = false
} = {}) {
const { site, localeIndex, page, theme } = useData()
const currentLang = computed(() => ({
label: site.value.locales[localeIndex.value]?.label,
link:
site.value.locales[localeIndex.value]?.link ||
(localeIndex.value === 'root' ? '/' : `/${localeIndex.value}/`)
}))
const localeLinks = computed(() =>
Object.entries(site.value.locales).flatMap(([key, value]) =>
removeCurrent && currentLang.value.label === value.label
? []
: {
text: value.label,
link: normalizeLink(
value.link || (key === 'root' ? '/' : `/${key}/`),
theme.value.i18nRouting !== false && correspondingLink,
page.value.relativePath.slice(currentLang.value.link.length - 1),
site.value.cleanUrls === 'disabled'
)
}
)
)
return { localeLinks, currentLang }
}
function normalizeLink(
link: string,
addPath: boolean,
path: string,
addExt: boolean
) {
return addPath
? link.replace(/\/$/, '') +
ensureStartingSlash(
path
.replace(/(^|\/)?index.md$/, '$1')
.replace(/\.md$/, addExt ? '.html' : '')
)
: link
}

@ -1,6 +1,5 @@
import type { DefaultTheme } from 'vitepress/theme'
import { ref, computed, watch } from 'vue'
import { useData, useRoute } from 'vitepress'
import { ref, watch } from 'vue'
import { useRoute } from 'vitepress'
export function useNav() {
const isScreenOpen = ref(false)
@ -36,34 +35,3 @@ export function useNav() {
toggleScreen
}
}
export function useLanguageLinks() {
const { site, localePath, theme } = useData()
return computed(() => {
const langs = site.value.langs
const localePaths = Object.keys(langs)
// one language
if (localePaths.length < 2) {
return null
}
const route = useRoute()
// intentionally remove the leading slash because each locale has one
const currentPath = route.path.replace(localePath.value, '')
const candidates = localePaths.map((localePath) => ({
text: langs[localePath].label,
link: `${localePath}${currentPath}`
}))
const selectText = theme.value.selectText || 'Languages'
return {
text: selectText,
items: candidates
} as DefaultTheme.NavItemWithChildren
})
}

@ -30,8 +30,13 @@ export function getHeaders(pageOutline: DefaultTheme.Config['outline']) {
export function resolveHeaders(
headers: MenuItem[],
levelsRange: Exclude<DefaultTheme.Config['outline'], false> = 2
range?: Exclude<DefaultTheme.Config['outline'], false>
) {
const levelsRange =
(typeof range === 'object' && !Array.isArray(range)
? range.level
: range) || 2
const levels: [number, number] =
typeof levelsRange === 'number'
? [levelsRange, levelsRange]

@ -1,5 +1,5 @@
import { computed } from 'vue'
import { useData } from 'vitepress'
import { useData } from './data.js'
import { isActive } from '../support/utils.js'
import { getSidebar, getFlatSideBarLinks } from '../support/sidebar.js'

@ -6,8 +6,9 @@ import {
ref,
watchEffect
} from 'vue'
import { useData, useRoute } from 'vitepress'
import { useRoute } from 'vitepress'
import { useMediaQuery } from '@vueuse/core'
import { useData } from './data.js'
import { getSidebar } from '../support/sidebar.js'
export function useSidebar() {

@ -289,6 +289,9 @@
.vp-doc [class*='language-'] pre,
.vp-doc [class*='language-'] code {
/*rtl:ignore*/
direction: ltr;
/*rtl:ignore*/
text-align: left;
white-space: pre;
word-spacing: normal;
@ -389,6 +392,7 @@
}
.vp-doc div[class*='language-'].line-numbers-mode {
/*rtl:ignore*/
padding-left: 32px;
}
@ -396,8 +400,10 @@
position: absolute;
top: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
z-index: 3;
/*rtl:ignore*/
border-right: 1px solid var(--vp-code-block-divider-color);
padding-top: 16px;
width: 32px;
@ -410,8 +416,11 @@
}
.vp-doc [class*='language-'] > button.copy {
/*rtl:ignore*/
direction: ltr;
position: absolute;
top: 8px;
/*rtl:ignore*/
right: 8px;
z-index: 3;
display: block;
@ -441,6 +450,7 @@
.vp-doc [class*='language-'] > button.copy.copied,
.vp-doc [class*='language-'] > button.copy:hover.copied {
/*rtl:ignore*/
border-radius: 0 4px 4px 0;
background-color: var(--vp-code-copy-code-hover-bg);
background-image: var(--vp-icon-copied);
@ -449,10 +459,12 @@
.vp-doc [class*='language-'] > button.copy.copied::before,
.vp-doc [class*='language-'] > button.copy:hover.copied::before {
position: relative;
/*rtl:ignore*/
left: -65px;
display: flex;
justify-content: center;
align-items: center;
/*rtl:ignore*/
border-radius: 4px 0 0 4px;
width: 64px;
height: 40px;
@ -468,6 +480,7 @@
.vp-doc [class*='language-'] > span.lang {
position: absolute;
top: 6px;
/*rtl:ignore*/
right: 12px;
z-index: 2;
font-size: 12px;

@ -8,7 +8,7 @@ import { ensureStartingSlash } from './utils.js'
* return empty array.
*/
export function getSidebar(
sidebar: DefaultTheme.Sidebar,
sidebar: DefaultTheme.Sidebar | undefined,
path: string
): DefaultTheme.SidebarGroup[] {
if (Array.isArray(sidebar)) {

@ -1,16 +1,8 @@
import { ref } from 'vue'
import { withBase, useData } from 'vitepress'
import { EXTERNAL_URL_RE, PATHNAME_PROTOCOL_RE } from '../../shared.js'
import { withBase } from 'vitepress'
import { useData } from '../composables/data.js'
import { isExternal, PATHNAME_PROTOCOL_RE } from '../../shared.js'
export const HASH_RE = /#.*$/
export const EXT_RE = /(index)?\.(md|html)$/
const inBrowser = typeof window !== 'undefined'
const hashRef = ref(inBrowser ? location.hash : '')
export function isExternal(path: string): boolean {
return EXTERNAL_URL_RE.test(path)
}
export { isExternal, isActive } from '../../shared.js'
export function throttleAndDebounce(fn: () => void, delay: number): () => void {
let timeoutId: NodeJS.Timeout
@ -33,42 +25,10 @@ export function throttleAndDebounce(fn: () => void, delay: number): () => void {
}
}
export function isActive(
currentPath: string,
matchPath?: string,
asRegex: boolean = false
): boolean {
if (matchPath === undefined) {
return false
}
currentPath = normalize(`/${currentPath}`)
if (asRegex) {
return new RegExp(matchPath).test(currentPath)
}
if (normalize(matchPath) !== currentPath) {
return false
}
const hashMatch = matchPath.match(HASH_RE)
if (hashMatch) {
return hashRef.value === hashMatch[0]
}
return true
}
export function ensureStartingSlash(path: string): string {
return /^\//.test(path) ? path : `/${path}`
}
export function normalize(path: string): string {
return decodeURI(path).replace(HASH_RE, '').replace(EXT_RE, '')
}
export function normalizeLink(url: string): string {
if (isExternal(url)) {
return url.replace(PATHNAME_PROTOCOL_RE, '')
@ -80,10 +40,13 @@ export function normalizeLink(url: string): string {
const normalizedPath =
pathname.endsWith('/') || pathname.endsWith('.html')
? url
: `${pathname.replace(
/(\.md)?$/,
site.value.cleanUrls === 'disabled' ? '.html' : ''
)}${search}${hash}`
: url.replace(
/(?:(^\.+)\/)?.*$/,
`$1${pathname.replace(
/(\.md)?$/,
site.value.cleanUrls === 'disabled' ? '.html' : ''
)}${search}${hash}`
)
return withBase(normalizedPath)
}

@ -4,16 +4,17 @@ import path from 'path'
import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup'
import { pathToFileURL } from 'url'
import { normalizePath, transformWithEsbuild } from 'vite'
import { resolveSiteDataByRoute, type SiteConfig } from '../config'
import type { SSGContext } from '../shared'
import type { SiteConfig } from '../config'
import {
createTitle,
EXTERNAL_URL_RE,
mergeHead,
notFoundPageData,
resolveSiteDataByRoute,
sanitizeFileName,
type HeadConfig,
type PageData
type PageData,
type SSGContext
} from '../shared'
import { slash } from '../utils/slash'
@ -146,7 +147,7 @@ export async function renderPage(
const html = `
<!DOCTYPE html>
<html lang="${siteData.lang}">
<html lang="${siteData.lang}" dir="${siteData.dir}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">

@ -10,38 +10,43 @@ import {
normalizePath,
type UserConfig as ViteConfig
} from 'vite'
import type { SSGContext } from '../../types/shared'
import { DEFAULT_THEME_PATH } from './alias'
import type { MarkdownOptions } from './markdown/markdown'
import {
APPEARANCE_KEY,
createLangDictionary,
type Awaitable,
type CleanUrlsMode,
type DefaultTheme,
type HeadConfig,
type LocaleConfig,
type LocaleSpecificConfig,
type PageData,
type SiteData
type SiteData,
type SSGContext
} from './shared'
export { resolveSiteDataByRoute } from './shared'
const debug = _debug('vitepress:config')
export interface UserConfig<ThemeConfig = any> {
export interface UserConfig<ThemeConfig = any>
extends LocaleSpecificConfig<ThemeConfig> {
extends?: RawConfigExports<ThemeConfig>
base?: string
lang?: string
title?: string
titleTemplate?: string | boolean
description?: string
head?: HeadConfig[]
srcDir?: string
srcExclude?: string[]
outDir?: string
cacheDir?: string
shouldPreload?: (link: string, page: string) => boolean
locales?: LocaleConfig<ThemeConfig>
appearance?: boolean | 'dark'
themeConfig?: ThemeConfig
locales?: Record<string, LocaleConfig>
markdown?: MarkdownOptions
lastUpdated?: boolean
/**
* MarkdownIt options
*/
markdown?: MarkdownOptions
/**
* Options to pass on to `@vitejs/plugin-vue`
*/
@ -51,12 +56,6 @@ export interface UserConfig<ThemeConfig = any> {
*/
vite?: ViteConfig
srcDir?: string
srcExclude?: string[]
outDir?: string
cacheDir?: string
shouldPreload?: (link: string, page: string) => boolean
/**
* Configure the scroll offset when the theme has a sticky header.
* Can be a number or a selector element to get the offset from.
@ -276,7 +275,10 @@ async function resolveUserConfig(
): Promise<[UserConfig, string | undefined, string[]]> {
// load user config
const configPath = supportedConfigExtensions
.map((ext) => resolve(root, `config.${ext}`))
.flatMap((ext) => [
resolve(root, `config/index.${ext}`),
resolve(root, `config.${ext}`)
])
.find(fs.pathExistsSync)
let userConfig: RawConfigExports = {}
@ -351,6 +353,7 @@ export async function resolveSiteData(
return {
lang: userConfig.lang || 'en-US',
dir: userConfig.dir || 'ltr',
title: userConfig.title || 'VitePress',
titleTemplate: userConfig.titleTemplate,
description: userConfig.description || 'A VitePress site',
@ -359,7 +362,6 @@ export async function resolveSiteData(
appearance: userConfig.appearance ?? true,
themeConfig: userConfig.themeConfig || {},
locales: userConfig.locales || {},
langs: createLangDictionary(userConfig),
scrollOffset: userConfig.scrollOffset || 90,
cleanUrls: userConfig.cleanUrls || 'disabled'
}

@ -9,6 +9,5 @@ export type {
SiteData,
HeadConfig,
Header,
LocaleConfig,
DefaultTheme
} from '../../types/shared'

@ -24,7 +24,8 @@ import { snippetPlugin } from './plugins/snippet'
import { preWrapperPlugin } from './plugins/preWrapper'
import { linkPlugin } from './plugins/link'
import { imagePlugin } from './plugins/image'
import type { Header } from '../shared'
export type { Header } from '../shared'
export type ThemeOptions =
| IThemeRegistration
@ -51,8 +52,6 @@ export interface MarkdownOptions extends MarkdownIt.Options {
export type MarkdownRenderer = MarkdownIt
export type { Header }
export const createMarkdownRenderer = async (
srcDir: string,
options: MarkdownOptions = {},

@ -1,9 +1,4 @@
import type {
HeadConfig,
LocaleConfig,
PageData,
SiteData
} from '../../types/shared.js'
import type { HeadConfig, PageData, SiteData } from '../../types/shared.js'
export type {
Awaitable,
@ -12,6 +7,7 @@ export type {
HeadConfig,
Header,
LocaleConfig,
LocaleSpecificConfig,
PageData,
PageDataPayload,
SiteData,
@ -21,6 +17,8 @@ export type {
export const EXTERNAL_URL_RE = /^[a-z]+:/i
export const PATHNAME_PROTOCOL_RE = /^pathname:\/\//
export const APPEARANCE_KEY = 'vitepress-theme-appearance'
export const HASH_RE = /#.*$/
export const EXT_RE = /(index)?\.(md|html)$/
export const inBrowser = typeof window !== 'undefined'
@ -33,71 +31,71 @@ export const notFoundPageData: PageData = {
lastUpdated: 0
}
function findMatchRoot(route: string, roots: string[]): string | undefined {
// first match to the routes with the most deep level.
roots.sort((a, b) => {
const levelDelta = b.split('/').length - a.split('/').length
if (levelDelta !== 0) {
return levelDelta
} else {
return b.length - a.length
}
})
export function isActive(
currentPath: string,
matchPath?: string,
asRegex: boolean = false
): boolean {
if (matchPath === undefined) {
return false
}
for (const r of roots) {
if (route.startsWith(r)) return r
currentPath = normalize(`/${currentPath}`)
if (asRegex) {
return new RegExp(matchPath).test(currentPath)
}
if (normalize(matchPath) !== currentPath) {
return false
}
const hashMatch = matchPath.match(HASH_RE)
if (hashMatch) {
return (inBrowser ? location.hash : '') === hashMatch[0]
}
return true
}
function resolveLocales<T>(
locales: Record<string, T>,
route: string
): T | undefined {
const localeRoot = findMatchRoot(route, Object.keys(locales))
return localeRoot ? locales[localeRoot] : undefined
export function normalize(path: string): string {
return decodeURI(path).replace(HASH_RE, '').replace(EXT_RE, '')
}
export function createLangDictionary(siteData: {
themeConfig?: Record<string, any>
locales?: Record<string, LocaleConfig>
}) {
const { locales } = siteData.themeConfig || {}
const siteLocales = siteData.locales
return locales && siteLocales
? Object.keys(locales).reduce((langs, path) => {
langs[path] = {
label: locales![path].label,
lang: siteLocales[path].lang
}
return langs
}, {} as Record<string, { lang: string; label: string }>)
: {}
export function isExternal(path: string): boolean {
return EXTERNAL_URL_RE.test(path)
}
// this merges the locales data to the main data by the route
/**
* this merges the locales data to the main data by the route
*/
export function resolveSiteDataByRoute(
siteData: SiteData,
route: string
relativePath: string
): SiteData {
route = cleanRoute(siteData, route)
const localeData = resolveLocales(siteData.locales || {}, route)
const localeThemeConfig = resolveLocales<any>(
siteData.themeConfig.locales || {},
route
)
// avoid object rest spread since this is going to run in the browser
// and spread is going to result in polyfill code
return Object.assign({}, siteData, localeData, {
themeConfig: Object.assign({}, siteData.themeConfig, localeThemeConfig, {
// clean the locales to reduce the bundle size
locales: {}
}),
lang: (localeData || siteData).lang,
// clean the locales to reduce the bundle size
locales: {},
langs: createLangDictionary(siteData)
const localeIndex =
Object.keys(siteData.locales).find(
(key) =>
key !== 'root' &&
!isExternal(key) &&
isActive(relativePath, `/${key}/`, true)
) || 'root'
return Object.assign({}, siteData, {
localeIndex,
lang: siteData.locales[localeIndex]?.lang ?? siteData.lang,
dir: siteData.locales[localeIndex]?.dir ?? siteData.dir,
title: siteData.locales[localeIndex]?.title ?? siteData.title,
titleTemplate:
siteData.locales[localeIndex]?.titleTemplate ?? siteData.titleTemplate,
description:
siteData.locales[localeIndex]?.description ?? siteData.description,
head: mergeHead(siteData.head, siteData.locales[localeIndex]?.head ?? []),
themeConfig: {
...siteData.themeConfig,
...siteData.locales[localeIndex]?.themeConfig
}
})
}
@ -136,20 +134,6 @@ function createTitleTemplate(
return ` | ${template}`
}
/**
* Clean up the route by removing the `base` path if it's set in config.
*/
function cleanRoute(siteData: SiteData, route: string): string {
if (!inBrowser) {
return route
}
const base = siteData.base
const baseWithoutSuffix = base.endsWith('/') ? base.slice(0, -1) : base
return route.slice(baseWithoutSuffix.length)
}
function hasTag(head: HeadConfig[], tag: HeadConfig) {
const [tagType, tagAttrs] = tag
if (tagType !== 'meta') return false

@ -1,3 +1,5 @@
import { DocSearchProps } from './docsearch.js'
export namespace DefaultTheme {
export interface Config {
/**
@ -18,10 +20,11 @@ export namespace DefaultTheme {
*
* @default 2
*/
outline?: number | [number, number] | 'deep' | false
outline?: Outline | Outline['level'] | false
/**
* Custom outline title in the aside component.
* @deprecated
* Use `outline.label` instead.
*
* @default 'On this page'
*/
@ -67,10 +70,19 @@ export namespace DefaultTheme {
footer?: Footer
/**
* Adds locale menu to the nav. This option should be used when you have
* your translated sites outside of the project.
* @default 'Appearance'
*/
darkModeSwitchLabel?: string
/**
* @default 'Menu'
*/
localeLinks?: LocaleLinks
sidebarMenuLabel?: string
/**
* @default 'Return to top'
*/
returnToTopLabel?: string
/**
* The algolia options. Leave it undefined to disable the search feature.
@ -81,6 +93,13 @@ export namespace DefaultTheme {
* The carbon ads options. Leave it undefined to disable the ads feature.
*/
carbonAds?: CarbonAdsOptions
/**
* Changing locale when current url is `/foo` will redirect to `/locale/foo`.
*
* @default true
*/
i18nRouting?: boolean
}
// nav -----------------------------------------------------------------------
@ -238,16 +257,11 @@ export namespace DefaultTheme {
sponsor?: string
}
// locales -------------------------------------------------------------------
export interface LocaleLinks {
text: string
items: LocaleLink[]
}
// outline -------------------------------------------------------------------
export interface LocaleLink {
text: string
link: string
export interface Outline {
level?: number | [number, number] | 'deep'
label?: string
}
// algolia -------------------------------------------------------------------
@ -256,15 +270,8 @@ export namespace DefaultTheme {
* The Algolia search options. Partially copied from
* `@docsearch/react/dist/esm/DocSearch.d.ts`
*/
export interface AlgoliaSearchOptions {
appId: string
apiKey: string
indexName: string
placeholder?: string
searchParameters?: any
disableUserPersonalization?: boolean
initialQuery?: string
buttonText?: string
export interface AlgoliaSearchOptions extends DocSearchProps {
locales?: Record<string, Partial<DocSearchProps>>
}
// carbon ads ----------------------------------------------------------------

143
types/docsearch.d.ts vendored

@ -0,0 +1,143 @@
export interface DocSearchProps {
appId: string
apiKey: string
indexName: string
placeholder?: string
searchParameters?: SearchOptions
disableUserPersonalization?: boolean
initialQuery?: string
translations?: DocSearchTranslations
}
export interface SearchOptions {
query?: string
similarQuery?: string
facetFilters?: string | string[]
optionalFilters?: string | string[]
numericFilters?: string | string[]
tagFilters?: string | string[]
sumOrFiltersScores?: boolean
filters?: string
page?: number
hitsPerPage?: number
offset?: number
length?: number
attributesToHighlight?: string[]
attributesToSnippet?: string[]
attributesToRetrieve?: string[]
highlightPreTag?: string
highlightPostTag?: string
snippetEllipsisText?: string
restrictHighlightAndSnippetArrays?: boolean
facets?: string[]
maxValuesPerFacet?: number
facetingAfterDistinct?: boolean
minWordSizefor1Typo?: number
minWordSizefor2Typos?: number
allowTyposOnNumericTokens?: boolean
disableTypoToleranceOnAttributes?: string[]
queryType?: 'prefixLast' | 'prefixAll' | 'prefixNone'
removeWordsIfNoResults?: 'none' | 'lastWords' | 'firstWords' | 'allOptional'
advancedSyntax?: boolean
advancedSyntaxFeatures?: ('exactPhrase' | 'excludeWords')[]
optionalWords?: string | string[]
disableExactOnAttributes?: string[]
exactOnSingleWordQuery?: 'attribute' | 'none' | 'word'
alternativesAsExact?: (
| 'ignorePlurals'
| 'singleWordSynonym'
| 'multiWordsSynonym'
)[]
enableRules?: boolean
ruleContexts?: string[]
distinct?: boolean | number
analytics?: boolean
analyticsTags?: string[]
synonyms?: boolean
replaceSynonymsInHighlight?: boolean
minProximity?: number
responseFields?: string[]
maxFacetHits?: number
percentileComputation?: boolean
clickAnalytics?: boolean
personalizationImpact?: number
enablePersonalization?: boolean
restrictSearchableAttributes?: string[]
sortFacetValuesBy?: 'count' | 'alpha'
typoTolerance?: boolean | 'min' | 'strict'
aroundLatLng?: string
aroundLatLngViaIP?: boolean
aroundRadius?: number | 'all'
aroundPrecision?: number | { from: number; value: number }[]
minimumAroundRadius?: number
insideBoundingBox?: number[][]
insidePolygon?: number[][]
ignorePlurals?: boolean | string[]
removeStopWords?: boolean | string[]
naturalLanguages?: string[]
getRankingInfo?: boolean
userToken?: string
enableABTest?: boolean
decompoundQuery?: boolean
relevancyStrictness?: number
}
export interface DocSearchTranslations {
button?: ButtonTranslations
modal?: ModalTranslations
}
export interface ButtonTranslations {
buttonText?: string
buttonAriaLabel?: string
}
export interface ModalTranslations extends ScreenStateTranslations {
searchBox?: SearchBoxTranslations
footer?: FooterTranslations
}
export interface ScreenStateTranslations {
errorScreen?: ErrorScreenTranslations
startScreen?: StartScreenTranslations
noResultsScreen?: NoResultsScreenTranslations
}
export interface SearchBoxTranslations {
resetButtonTitle?: string
resetButtonAriaLabel?: string
cancelButtonText?: string
cancelButtonAriaLabel?: string
}
export interface FooterTranslations {
selectText?: string
selectKeyAriaLabel?: string
navigateText?: string
navigateUpKeyAriaLabel?: string
navigateDownKeyAriaLabel?: string
closeText?: string
closeKeyAriaLabel?: string
searchByText?: string
}
export interface ErrorScreenTranslations {
titleText?: string
helpText?: string
}
export interface StartScreenTranslations {
recentSearchesTitle?: string
noRecentSearchesText?: string
saveRecentSearchButtonTitle?: string
removeRecentSearchButtonTitle?: string
favoriteSearchesTitle?: string
removeFavoriteSearchButtonTitle?: string
}
export interface NoResultsScreenTranslations {
noResultsText?: string
suggestedQueryText?: string
reportMissingResultsText?: string
reportMissingResultsLinkText?: string
}

57
types/shared.d.ts vendored

@ -51,14 +51,8 @@ export type CleanUrlsMode =
export interface SiteData<ThemeConfig = any> {
base: string
cleanUrls?: CleanUrlsMode
/**
* Language of the site as it should be set on the `html` element.
*
* @example `en-US`, `zh-CN`
*/
lang: string
dir: string
title: string
titleTemplate?: string | boolean
description: string
@ -66,44 +60,14 @@ export interface SiteData<ThemeConfig = any> {
appearance: boolean | 'dark'
themeConfig: ThemeConfig
scrollOffset: number | string
locales: Record<string, LocaleConfig>
/**
* Available locales for the site when it has defined `locales` in its
* `themeConfig`. This object is otherwise empty. Keys are paths like `/` or
* `/zh/`.
*/
langs: Record<
string,
{
/**
* Lang attribute as set on the `<html>` element.
* @example `en-US`, `zh-CN`
*/
lang: string
/**
* Label to display in the language menu.
* @example `English`, `简体中文`
*/
label: string
}
>
locales: LocaleConfig<ThemeConfig>
localeIndex?: string
}
export type HeadConfig =
| [string, Record<string, string>]
| [string, Record<string, string>, string]
export interface LocaleConfig {
lang: string
title?: string
titleTemplate?: string | boolean
description?: string
head?: HeadConfig[]
label?: string
selectText?: string
}
export interface PageDataPayload {
path: string
pageData: PageData
@ -112,3 +76,18 @@ export interface PageDataPayload {
export interface SSGContext extends SSRContext {
content: string
}
export interface LocaleSpecificConfig<ThemeConfig = any> {
lang?: string
dir?: string
title?: string
titleTemplate?: string | boolean
description?: string
head?: HeadConfig[]
themeConfig?: ThemeConfig
}
export type LocaleConfig<ThemeConfig = any> = Record<
string,
LocaleSpecificConfig<ThemeConfig> & { label: string; link?: string }
>

Loading…
Cancel
Save