feat: add i18n support

This commit is contained in:
pengzhanbo 2023-06-16 16:41:45 +08:00
parent 30fe922a0c
commit 249ea11fbb
20 changed files with 400 additions and 102 deletions

View File

@ -21,6 +21,7 @@ export type NotesSidebarItem = {
dir?: string
collapsed?: boolean
items?: NotesSidebar
icon?: string
}
export type NotesData = Record<string, NotesSidebarItem[]>

View File

@ -8,6 +8,8 @@ const props = defineProps<{
tag?: string
href?: string
noIcon?: boolean
target?: string
rel?: string
}>()
const router = useRouter()
@ -33,8 +35,8 @@ const linkTo = (e: Event) => {
class="auto-link"
:class="{ link: href }"
:href="href ? normalizeLink(href) : undefined"
:target="isExternal ? '_blank' : undefined"
:rel="isExternal ? 'noreferrer' : undefined"
:target="target || (isExternal ? '_blank' : undefined)"
:rel="rel || (isExternal ? 'noreferrer' : undefined)"
@click="linkTo($event)"
>
<slot />

View File

@ -9,6 +9,7 @@ import NavBarMenu from './NavBarMenu.vue'
import NavBarSearch from './NavBarSearch.vue'
import NavBarSocialLinks from './NavBarSocialLinks.vue'
import NavBarTitle from './NavBarTitle.vue'
import NavBarTranslations from './NavBarTranslations.vue'
defineProps<{
isScreenOpen: boolean
@ -38,6 +39,7 @@ const classes = computed(() => ({
<div class="content-body">
<NavBarSearch class="search" />
<NavBarMenu class="menu" />
<NavBarTranslations class="translations" />
<NavBarAppearance class="appearance" />
<NavBarSocialLinks class="social-links" />
<NavBarExtra class="extra" />

View File

@ -1,11 +1,14 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useThemeLocaleData } from '../../composables/index.js'
import { useLangs } from '../../composables/langs.js'
import Flyout from '../Flyout/index.vue'
import MenuLink from '../Flyout/MenuLink.vue'
import SocialLinks from '../SocialLinks.vue'
import SwitchAppearance from '../SwitchAppearance.vue'
const theme = useThemeLocaleData()
const { localeLinks, currentLang } = useLangs({ correspondingLink: true })
const hasExtraContent = computed(
() => theme.value.appearance || theme.value.social
@ -14,6 +17,17 @@ const hasExtraContent = computed(
<template>
<Flyout v-if="hasExtraContent" class="navbar-extra" label="extra navigation">
<div
v-if="localeLinks.length && currentLang.label"
class="group translations"
>
<p class="trans-title">{{ currentLang.label }}</p>
<template v-for="locale in localeLinks" :key="locale.link">
<MenuLink :item="locale" />
</template>
</div>
<div v-if="theme.appearance" class="group">
<div class="item appearance">
<p class="label">Appearance</p>

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import { useLangs } from '../../composables/langs.js'
import { useThemeLocaleData } from '../../composables/themeData.js'
import Flyout from '../Flyout/index.vue'
import MenuLink from '../Flyout/MenuLink.vue'
import IconLanguages from '../icons/IconLanguages.vue'
const theme = useThemeLocaleData()
const { currentLang, localeLinks } = useLangs()
</script>
<template>
<Flyout
v-if="localeLinks.length && currentLang.label"
class="navbar-translations"
:icon="IconLanguages"
:label="theme.selectLanguageText || 'change language'"
>
<div class="items">
<p class="title">{{ currentLang.label }}</p>
<template v-for="locale in localeLinks" :key="locale.link">
<MenuLink :item="locale" />
</template>
</div>
</Flyout>
</template>
<style lang="scss" scoped>
.navbar-translations {
display: none;
}
@media (min-width: 1280px) {
.navbar-translations {
display: flex;
align-items: center;
}
}
.title {
padding: 0 24px 0 12px;
line-height: 32px;
font-size: 14px;
font-weight: 700;
color: var(--vp-c-text-1);
}
</style>

View File

@ -4,6 +4,7 @@ import { ref } from 'vue'
import NavScreenAppearance from './NavScreenAppearance.vue'
import NavScreenMenu from './NavScreenMenu.vue'
import NavScreenSocialLinks from './NavScreenSocialLinks.vue'
import NavScreenTranslates from './NavScreenTranslations.vue'
defineProps<{
open: boolean
@ -29,6 +30,7 @@ function unlockBodyScroll() {
<div v-if="open" ref="screen" class="nav-screen">
<div class="container">
<NavScreenMenu class="menu" />
<NavScreenTranslates class="translations" />
<NavScreenAppearance class="appearance" />
<NavScreenSocialLinks class="social-links" />
</div>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useLangs } from '../../composables/langs.js'
import AutoLink from '../AutoLink.vue'
import IconChevronDown from '../icons/IconChevronDown.vue'
import IconLanguages from '../icons/IconLanguages.vue'
const { localeLinks, currentLang } = useLangs({ correspondingLink: true })
const isOpen = ref(false)
function toggle() {
isOpen.value = !isOpen.value
}
</script>
<template>
<div
v-if="localeLinks.length && currentLang.label"
class="nav-screen-translations"
:class="{ open: isOpen }"
>
<button class="title" @click="toggle">
<IconLanguages class="icon lang" />
{{ currentLang.label }}
<IconChevronDown class="icon chevron" />
</button>
<ul class="list">
<li v-for="locale in localeLinks" :key="locale.link" class="item">
<AutoLink class="link" :href="locale.link">{{ locale.text }}</AutoLink>
</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.nav-screen-translations {
height: 24px;
overflow: hidden;
}
.nav-screen-translations.open {
height: auto;
}
.title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-text-1);
}
.icon {
width: 16px;
height: 16px;
fill: currentColor;
}
.icon.lang {
margin-right: 8px;
}
.icon.chevron {
margin-left: 4px;
}
.list {
padding: 4px 0 0 24px;
}
.link {
line-height: 32px;
font-size: 13px;
color: var(--vp-c-text-1);
}
</style>

View File

@ -11,7 +11,7 @@ const page = usePageData<PlumeThemePageData>()
const { isScreenOpen, closeScreen, toggleScreen } = useNav()
const fixed = computed(() => {
return page.value.isBlogPost || page.value.type === 'blog'
return page.value.isBlogPost || page.value.frontmatter.type === 'blog'
})
provide('close-screen', closeScreen)

View File

@ -1,10 +1,13 @@
<script lang="ts" setup>
import { usePageLang } from '@vuepress/client'
import { useBlogPostData } from '@vuepress-plume/vuepress-plugin-blog-data/client'
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
import PostItem from './PostItem.vue'
const locale = usePageLang()
const list = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
const postList = computed(() => {
@ -21,7 +24,7 @@ const postList = computed(() => {
return next.sticky > prev.sticky ? 1 : -1
}),
...otherList,
]
].filter((item) => item.lang === locale.value)
})
</script>
<template>

View File

@ -0,0 +1,56 @@
import { usePageData, usePageLang, useSiteData } from '@vuepress/client'
import { computed } from 'vue'
import type { PlumeThemePageData } from '../../shared/index.js'
import { ensureStartingSlash } from '../utils/index.js'
import { useThemeData } from './themeData.js'
export function useLangs({
removeCurrent = true,
correspondingLink = false,
} = {}) {
const page = usePageData<PlumeThemePageData>()
const site = useSiteData()
const theme = useThemeData()
const locale = usePageLang()
const currentLang = computed(() => {
const link = locale.value === site.value.lang ? '/' : `/${locale.value}/`
return {
label: theme.value.locales?.[link]?.selectLanguageName,
link,
}
})
const localeLinks = computed(() =>
Object.entries(theme.value.locales || {}).flatMap(([key, value]) =>
removeCurrent && currentLang.value.label === value.selectLanguageName
? []
: {
text: value.selectLanguageName,
link: normalizeLink(
key,
correspondingLink,
page.value.path.slice(currentLang.value.link.length - 1),
true
),
}
)
)
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
}

View File

@ -21,6 +21,8 @@ import {
const page = usePageData<PlumeThemePageData>()
console.log(page)
const {
isOpen: isSidebarOpen,
open: openSidebar,
@ -49,7 +51,7 @@ provide('is-sidebar-open', isSidebarOpen)
<Sidebar :open="isSidebarOpen" />
<LayoutContent>
<Home v-if="page.frontmatter.home" />
<Blog v-else-if="page.type === 'blog'" />
<Blog v-else-if="page.frontmatter.type === 'blog'" />
<Page v-else />
<VFooter />
</LayoutContent>

View File

@ -277,6 +277,10 @@
color: var(--vp-c-brand-dark);
}
.plume-content .vp-code-tabs-nav {
margin: 0.85rem 0 0;
}
// .plume-content div[class*='language-'] {
// position: relative;
// margin: 16px -24px;

View File

@ -63,3 +63,7 @@ export function throttleAndDebounce(fn: () => void, delay: number): () => void {
}
}
}
export function ensureStartingSlash(path: string): string {
return /^\//.test(path) ? path : `/${path}`
}

View File

@ -1,38 +1,49 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import type { App } from '@vuepress/core'
import { resolveLocalePath } from '@vuepress/shared'
import type {
AutoFrontmatterOptions,
FormatterArray,
FormatterObject,
} from '@vuepress-plume/vuepress-plugin-auto-frontmatter'
import type {
NotesDataOptions,
NotesItem,
} from '@vuepress-plume/vuepress-plugin-notes-data'
import type { NotesItem } from '@vuepress-plume/vuepress-plugin-notes-data'
import { format } from 'date-fns'
import { customAlphabet } from 'nanoid'
import type { PlumeThemeLocaleOptions } from '../shared/index.js'
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8)
const require = createRequire(process.cwd())
const getPackage = () => {
let pkg = {} as any
try {
pkg = require('package.json') || {}
} catch {}
return pkg
}
export default function (
export default function autoFrontmatter(
app: App,
localeOption: PlumeThemeLocaleOptions
): AutoFrontmatterOptions {
const sourceDir = app.dir.source()
const require = createRequire(process.cwd())
let pkg = {} as any
try {
pkg = require(path.join(process.cwd(), './package.json')) || {}
} catch {}
const pkg = getPackage()
const articlePrefix = localeOption.article || '/article/'
const {
dir,
link: notesLink,
notes: notesList,
} = localeOption.notes as NotesDataOptions
const notesDir = dir.replace(/^\//, '')
const baseFormatter = {
const locales = (app.siteData.locales || {}) as PlumeThemeLocaleOptions
const localesNotesDirs = Object.keys(locales)
.map((locale) => {
const notes = localeOption.locales?.[locale].notes
if (!notes) return ''
const dir = notes.dir
console.log(locale, dir)
return dir ? path.join(locale, dir).replace(/^\//, '') : ''
})
.filter(Boolean)
console.log('locales notes dirs', Object.keys(locales), localesNotesDirs)
const baseFormatter: FormatterObject = {
author(author: string) {
if (author) return author
return localeOption.avatar?.name || pkg.author || ''
@ -42,59 +53,92 @@ export default function (
return format(new Date(createTime), 'yyyy/MM/dd hh:mm:ss')
},
}
const resolveLocale = (filepath: string) => {
const file = path.join('/', path.relative(sourceDir, filepath))
return resolveLocalePath(localeOption.locales!, file)
}
const notesByLocale = (locale: string) => {
const notes = localeOption.locales![locale].notes || localeOption.notes
if (notes === false) return undefined
return notes
}
const findNote = (filepath: string) => {
const file = path.relative(sourceDir, filepath)
const file = path.join('/', path.relative(sourceDir, filepath))
const locale = resolveLocalePath(localeOption.locales!, file)
const notes = notesByLocale(locale)
if (!notes) return undefined
const notesList = notes?.notes || []
const notesDir = notes?.dir || ''
return notesList.find((note) =>
file.startsWith(path.join(notesDir.replace(/^\//, ''), note.dir))
file.startsWith(path.join(locale, notesDir, note.dir))
)
}
const getCurrentDirname = (note: NotesItem | undefined, filepath: string) => {
const dirList =
(note?.dir || path.dirname(filepath))
.replace(/^\/|\/$/g, '')
.split('/') || []
const dirList = (note?.dir || path.dirname(filepath))
.replace(/^\/|\/$/g, '')
.split('/')
return dirList.length > 0 ? dirList[dirList.length - 1] : ''
}
return {
include: ['**/*.md'],
formatter: [
{
// note 首页链接
include: path.join(notesDir, `**/{readme,README,index}.md`),
formatter: {
title(title: string, { filepath }) {
if (title) return title
const note = findNote(filepath)
if (note?.text) return note.text
return getCurrentDirname(note, filepath) || ''
},
...baseFormatter,
permalink(permalink: string, { filepath }) {
if (permalink) return permalink
const note = findNote(filepath)
const dirname = getCurrentDirname(note, filepath)
return path.join(notesLink, note?.link || dirname, '/')
},
},
},
{
include: path.join(notesDir, '**/*.md'),
formatter: {
title(title: string, { filepath }) {
if (title) return title
const basename = path.basename(filepath, '.md')
return basename
},
...baseFormatter,
permalink(permalink: string, { filepath }) {
if (permalink) return permalink
const note = findNote(filepath)
const dirname = getCurrentDirname(note, filepath)
return path.join(notesLink, note?.link || dirname, nanoid(), '/')
},
},
},
localesNotesDirs.length
? {
// note 首页链接
include: localesNotesDirs.map((dir) =>
path.join(dir, '**/{readme,README,index}.md')
),
formatter: {
title(title: string, { filepath }) {
if (title) return title
const note = findNote(filepath)
if (note?.text) return note.text
return getCurrentDirname(note, filepath) || ''
},
...baseFormatter,
permalink(permalink: string, { filepath }) {
if (permalink) return permalink
const locale = resolveLocale(filepath)
const notes = notesByLocale(locale)
const note = findNote(filepath)
return path.join(
locale,
notes?.link || '',
note?.link || getCurrentDirname(note, filepath),
'/'
)
},
},
}
: '',
localesNotesDirs.length
? {
include: localesNotesDirs.map((dir) => path.join(dir, '**/**.md')),
formatter: {
title(title: string, { filepath }) {
if (title) return title
const basename = path.basename(filepath, '.md')
return basename
},
...baseFormatter,
permalink(permalink: string, { filepath }) {
if (permalink) return permalink
const locale = resolveLocale(filepath)
const note = findNote(filepath)
const notes = notesByLocale(locale)
return path.join(
locale,
notes?.link || '',
note?.link || getCurrentDirname(note, filepath),
nanoid(),
'/'
)
},
},
}
: '',
{
include: '**/{readme,README,index}.md',
formatter: {},
@ -108,12 +152,13 @@ export default function (
return basename
},
...baseFormatter,
permalink(permalink: string) {
permalink(permalink: string, { filepath }) {
if (permalink) return permalink
return path.join(articlePrefix, nanoid(), '/')
const locale = resolveLocale(filepath)
return path.join(locale, articlePrefix, nanoid(), '/')
},
},
},
] as FormatterArray,
].filter(Boolean) as FormatterArray,
}
}

View File

@ -1,4 +1,3 @@
import merge from 'lodash.merge'
import type { PlumeThemeLocaleOptions } from '../shared/index.js'
export const defaultLocaleOption: Partial<PlumeThemeLocaleOptions> = {
@ -13,8 +12,23 @@ export const defaultLocaleOption: Partial<PlumeThemeLocaleOptions> = {
message:
'Power by <a target="_blank" href="https://v2.vuepress.vuejs.org/">Vuepress</a> & <a target="_blank" href="https://github.com/pengzhanbo/vuepress-theme-plume">vuepress-theme-plume</a>',
},
appearance: true,
}
export const mergeLocaleOptions = (options: PlumeThemeLocaleOptions) => {
return merge(defaultLocaleOption, options)
if (!options.locales) {
options.locales = {}
}
if (!options.locales['/']) {
options.locales['/'] = {}
}
Object.assign(options, {
...defaultLocaleOption,
...options,
})
Object.assign(options.locales['/'], {
...defaultLocaleOption,
...options.locales['/'],
})
return options
}

View File

@ -8,3 +8,5 @@ export const definePlumeNotesConfig = (
): NotesDataOptions => notes
export const definePlumeNotesItemConfig = (item: NotesItem): NotesItem => item
export type { NotesDataOptions, NotesItem }

View File

@ -1,3 +1,4 @@
import path from 'node:path'
import type { App, PluginConfig } from '@vuepress/core'
import { activeHeaderLinksPlugin } from '@vuepress/plugin-active-header-links'
import { docsearchPlugin } from '@vuepress/plugin-docsearch'
@ -34,10 +35,13 @@ export const setupPlugins = (
): PluginConfig => {
const isProd = !app.env.isDev
let notesDir: string | undefined
if (localeOptions.notes !== false) {
notesDir = localeOptions.notes?.dir
}
const locales = (localeOptions.locales || {}) as PlumeThemeLocaleOptions
const localeNotesDirs = Object.keys(locales)
.map((locale) => {
const dir = locales[locale].notes?.dir || ''
return dir ? path.join(locale, dir, '**').replace(/^\//, '') : ''
})
.filter(Boolean)
return [
palettePlugin({ preset: 'sass' }),
@ -56,7 +60,7 @@ export const setupPlugins = (
'**/{README,readme,index}.md',
'.vuepress/',
...(localeOptions.blog?.exclude || []),
notesDir ? `${notesDir}/**` : '',
...localeNotesDirs,
].filter(Boolean),
sortBy: 'createTime',
excerpt: true,
@ -72,6 +76,7 @@ export const setupPlugins = (
tags: page.frontmatter.tags,
sticky: page.frontmatter.sticky,
createTime: page.data.frontmatter.createTime,
lang: page.lang,
}
},
}),

View File

@ -1,3 +1,4 @@
import path from 'node:path'
import type { App, Page } from '@vuepress/core'
import { createPage } from '@vuepress/core'
import type {
@ -10,30 +11,51 @@ export async function setupPage(
app: App,
localeOption: PlumeThemeLocaleOptions
) {
const blogPage = await createPage(app, {
path: localeOption.blog?.link,
})
const locales = Object.keys(app.siteData.locales || {})
for (const [, locale] of locales.entries()) {
const blog = localeOption.locales?.[locale]?.blog
const blogPage = await createPage(app, {
path: blog?.link
? blog.link
: path.join('/', locale, localeOption.blog?.link || ''),
frontmatter: {
lang: locale.replace(/^\/|\/$/g, '') || app.siteData.lang,
type: 'blog',
},
})
app.pages.push(blogPage)
app.pages.push(blogPage)
}
}
let uuid = 10000
const cache: Record<string, number> = {}
const RE_CATEGORY = /^(\d+)?(?:\.?)([^]+)$/
export function autoCategory(
app: App,
page: Page<PlumeThemePageData>,
options: PlumeThemeLocaleOptions
) {
const pagePath = page.filePathRelative
if (page.data.type || !pagePath) return
const { notes } = options
if (notes && notes.link && page.path.startsWith(notes.link)) return
const categoryList: PageCategoryData[] = pagePath
if (page.frontmatter.type || !pagePath) return
const locales = Object.keys(app.siteData.locales)
const notesLinks: string[] = []
for (const [, locale] of locales.entries()) {
const notes = options.locales?.[locale]?.notes
if (notes && notes.link) notesLinks.push(path.join(locale, notes.link))
}
if (notesLinks.some((link) => page.path.startsWith(link))) return
const RE_LOCALE = new RegExp(
`^(${locales.filter((l) => l !== '/').join('|')})`
)
const categoryList: PageCategoryData[] = `/${pagePath}`
.replace(RE_LOCALE, '')
.replace(/^\//, '')
.split('/')
.slice(0, -1)
.map((category) => {
const match = category.match(/^(\d+)?(?:\.?)([^]+)$/) || []
const match = category.match(RE_CATEGORY) || []
!cache[match[2]] && !match[1] && (cache[match[2]] = uuid++)
return {
type: Number(match[1] || cache[match[2]]),

View File

@ -6,6 +6,9 @@ import { setupPlugins } from './plugins.js'
import { autoCategory, pageContentRendered, setupPage } from './setupPages.js'
const __dirname = getDirname(import.meta.url)
const name = '@vuepress-plume/theme-plume'
const resolve = (...args: string[]) => path.resolve(__dirname, '../', ...args)
const templates = (url: string) => resolve('../templates', url)
export const plumeTheme = ({
themePlugins = {},
@ -14,32 +17,24 @@ export const plumeTheme = ({
localeOptions = mergeLocaleOptions(localeOptions)
return (app: App) => {
return {
name: '@vuepress-plume/theme-plume',
templateBuild: path.resolve(__dirname, '../../templates/build.html'),
name,
templateBuild: templates('build.html'),
clientConfigFile: resolve('client/config.js'),
alias: {
...Object.fromEntries(
fs
.readdirSync(path.resolve(__dirname, '../client/components'))
.readdirSync(resolve('client/components'))
.filter((file) => file.endsWith('.vue'))
.map((file) => [
`@theme/${file}`,
path.resolve(__dirname, '../client/components', file),
resolve('client/components', file),
])
),
},
clientConfigFile: path.resolve(__dirname, '../client/config.js'),
plugins: setupPlugins(app, themePlugins, localeOptions),
onInitialized: async (app) => {
await setupPage(app, localeOptions)
},
onInitialized: async (app) => await setupPage(app, localeOptions),
extendsPage: (page: Page<PlumeThemePageData>) => {
if (
localeOptions.blog?.link &&
page.path.startsWith(localeOptions.blog.link)
) {
page.data.type = 'blog'
}
autoCategory(page, localeOptions)
autoCategory(app, page, localeOptions)
pageContentRendered(page)
},
}

View File

@ -148,15 +148,15 @@ export interface PlumeThemeLocaleData extends LocaleData {
/**
* language text
*/
// selectLanguageText?: string
selectLanguageText?: string
/**
* language aria label
*/
// selectLanguageAriaLabel?: string
selectLanguageAriaLabel?: string
/**
* language name
*/
// selectLanguageName?: string
selectLanguageName?: string
/**
* repository of navbar
*/