From 44fba50a0ee42d661732d81d75d2d69466a02e8e Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Sat, 3 Feb 2024 10:59:30 +0800 Subject: [PATCH] feat(plugin-redirect): add redirect plugin (#53) --- docs/.vuepress/configs/navbar/en.ts | 1 + docs/.vuepress/configs/navbar/zh.ts | 1 + docs/.vuepress/configs/sidebar/en.ts | 1 + docs/.vuepress/configs/sidebar/zh.ts | 1 + docs/plugins/redirect.md | 236 +++++++++++++++++ docs/zh/plugins/redirect.md | 244 ++++++++++++++++++ e2e/docs/.vuepress/config.ts | 7 + e2e/docs/redirect/final.md | 5 + e2e/docs/redirect/to.md | 5 + e2e/package.json | 1 + e2e/tests/plugin-redirect/redirect.cy.ts | 25 ++ plugins/plugin-redirect/package.json | 57 ++++ plugins/plugin-redirect/src/cli/index.ts | 132 ++++++++++ .../src/client/components/LanguageSwitch.ts | 167 ++++++++++++ .../src/client/composables/index.ts | 1 + .../composables/setupDevServerRedirect.ts | 109 ++++++++ plugins/plugin-redirect/src/client/config.ts | 13 + plugins/plugin-redirect/src/client/define.ts | 7 + .../src/client/styles/language-switch.scss | 67 +++++ .../src/node/ensureRootHomePage.ts | 88 +++++++ .../plugin-redirect/src/node/frontmatter.ts | 4 + .../generateAutoLocaleRedirectFiles.ts | 45 ++++ .../node/generate/generateRedirectFiles.ts | 40 +++ .../node/generate/getLocaleRedirectHTML.ts | 98 +++++++ .../src/node/generate/getRedirectHTML.ts | 18 ++ .../src/node/generate/index.ts | 2 + .../src/node/getRedirectLocaleConfig.ts | 64 +++++ .../src/node/getRedirectMap.ts | 50 ++++ .../src/node/handleRedirectTo.ts | 27 ++ plugins/plugin-redirect/src/node/index.ts | 3 + plugins/plugin-redirect/src/node/locales.ts | 151 +++++++++++ plugins/plugin-redirect/src/node/logger.ts | 5 + plugins/plugin-redirect/src/node/options.ts | 29 +++ .../src/node/redirectPlugin.ts | 76 ++++++ plugins/plugin-redirect/src/shared/index.ts | 3 + .../src/shared/localeConfig.ts | 59 +++++ plugins/plugin-redirect/src/shared/locales.ts | 30 +++ .../src/shared/normalizePath.ts | 6 + .../src/shims-redirectMap.d.ts | 3 + .../tests/shared/normalizePath.spec.ts | 62 +++++ plugins/plugin-redirect/tsconfig.build.json | 10 + pnpm-lock.yaml | 24 ++ tsconfig.build.json | 1 + 43 files changed, 1978 insertions(+) create mode 100644 docs/plugins/redirect.md create mode 100644 docs/zh/plugins/redirect.md create mode 100644 e2e/docs/redirect/final.md create mode 100644 e2e/docs/redirect/to.md create mode 100644 e2e/tests/plugin-redirect/redirect.cy.ts create mode 100644 plugins/plugin-redirect/package.json create mode 100644 plugins/plugin-redirect/src/cli/index.ts create mode 100644 plugins/plugin-redirect/src/client/components/LanguageSwitch.ts create mode 100644 plugins/plugin-redirect/src/client/composables/index.ts create mode 100644 plugins/plugin-redirect/src/client/composables/setupDevServerRedirect.ts create mode 100644 plugins/plugin-redirect/src/client/config.ts create mode 100644 plugins/plugin-redirect/src/client/define.ts create mode 100644 plugins/plugin-redirect/src/client/styles/language-switch.scss create mode 100644 plugins/plugin-redirect/src/node/ensureRootHomePage.ts create mode 100644 plugins/plugin-redirect/src/node/frontmatter.ts create mode 100644 plugins/plugin-redirect/src/node/generate/generateAutoLocaleRedirectFiles.ts create mode 100644 plugins/plugin-redirect/src/node/generate/generateRedirectFiles.ts create mode 100644 plugins/plugin-redirect/src/node/generate/getLocaleRedirectHTML.ts create mode 100644 plugins/plugin-redirect/src/node/generate/getRedirectHTML.ts create mode 100644 plugins/plugin-redirect/src/node/generate/index.ts create mode 100644 plugins/plugin-redirect/src/node/getRedirectLocaleConfig.ts create mode 100644 plugins/plugin-redirect/src/node/getRedirectMap.ts create mode 100644 plugins/plugin-redirect/src/node/handleRedirectTo.ts create mode 100644 plugins/plugin-redirect/src/node/index.ts create mode 100644 plugins/plugin-redirect/src/node/locales.ts create mode 100644 plugins/plugin-redirect/src/node/logger.ts create mode 100644 plugins/plugin-redirect/src/node/options.ts create mode 100644 plugins/plugin-redirect/src/node/redirectPlugin.ts create mode 100644 plugins/plugin-redirect/src/shared/index.ts create mode 100644 plugins/plugin-redirect/src/shared/localeConfig.ts create mode 100644 plugins/plugin-redirect/src/shared/locales.ts create mode 100644 plugins/plugin-redirect/src/shared/normalizePath.ts create mode 100644 plugins/plugin-redirect/src/shims-redirectMap.d.ts create mode 100644 plugins/plugin-redirect/tests/shared/normalizePath.spec.ts create mode 100644 plugins/plugin-redirect/tsconfig.build.json diff --git a/docs/.vuepress/configs/navbar/en.ts b/docs/.vuepress/configs/navbar/en.ts index 72aa9409a0..4a79074dee 100644 --- a/docs/.vuepress/configs/navbar/en.ts +++ b/docs/.vuepress/configs/navbar/en.ts @@ -24,6 +24,7 @@ export const navbarEn: NavbarConfig = [ '/plugins/google-analytics', '/plugins/medium-zoom', '/plugins/nprogress', + '/plugins/redirect', '/plugins/register-components', ], }, diff --git a/docs/.vuepress/configs/navbar/zh.ts b/docs/.vuepress/configs/navbar/zh.ts index 79e54885f0..5341cc8ff4 100644 --- a/docs/.vuepress/configs/navbar/zh.ts +++ b/docs/.vuepress/configs/navbar/zh.ts @@ -24,6 +24,7 @@ export const navbarZh: NavbarConfig = [ '/zh/plugins/google-analytics', '/zh/plugins/medium-zoom', '/zh/plugins/nprogress', + '/zh/plugins/redirect', '/zh/plugins/register-components', ], }, diff --git a/docs/.vuepress/configs/sidebar/en.ts b/docs/.vuepress/configs/sidebar/en.ts index d88994b8ab..ccdc6ce22e 100644 --- a/docs/.vuepress/configs/sidebar/en.ts +++ b/docs/.vuepress/configs/sidebar/en.ts @@ -13,6 +13,7 @@ export const sidebarEn: SidebarConfig = { '/plugins/google-analytics', '/plugins/medium-zoom', '/plugins/nprogress', + '/plugins/redirect', '/plugins/register-components', ], }, diff --git a/docs/.vuepress/configs/sidebar/zh.ts b/docs/.vuepress/configs/sidebar/zh.ts index 412530e736..4dec755837 100644 --- a/docs/.vuepress/configs/sidebar/zh.ts +++ b/docs/.vuepress/configs/sidebar/zh.ts @@ -13,6 +13,7 @@ export const sidebarZh: SidebarConfig = { '/zh/plugins/google-analytics', '/zh/plugins/medium-zoom', '/zh/plugins/nprogress', + '/zh/plugins/redirect', '/zh/plugins/register-components', ], }, diff --git a/docs/plugins/redirect.md b/docs/plugins/redirect.md new file mode 100644 index 0000000000..0f24fd8795 --- /dev/null +++ b/docs/plugins/redirect.md @@ -0,0 +1,236 @@ +# redirect + + + +This plugin can automatically handle redirects for your site. + +## Usage + +```bash +npm i -D @vuepress/plugin-redirect@next +``` + +```ts +import { redirectPlugin } from '@vuepress/plugin-redirect' + +export default { + plugins: [ + redirectPlugin({ + // options + }), + ], +} +``` + +### Control Page Redirection + +If you change the address of an existing page, you can use the `redirectFrom` option in Frontmatter to redirect to the address of this page, which ensures that users are redirected to the new address when they visit the old link. + +If you need to redirect an existing page to a new page, you can use the `redirectTo` option in Frontmatter to set the address to redirect to. This way the page will redirect to the new address when accessed. + +You can also set `config` with a redirect map in plugin options, see [config](#config) for more details. + +### Auto Locales + +The plugin can automatically redirect non-multilingual links to the multilingual pages the user needs based on the user's language preference. + +To achieve this, you need to leave the default language directory (`/`) blank and set `autoLocale: true` in plugin options. The plugin will automatically redirect to the correct page according to the user's language. + +I.E.: you need to set the following directory structure: + +``` +. +├── en +│ ├── ... +│ ├── page.md +│ └── README.md +├── zh +│ ├── ... +│ ├── page.md +│ └── README.md +└── other_languages + ├── ... + ├── page.md + └── README.md +``` + +And set `locales` in theme options with: + +```js +export default { + locales: { + '/en/': { + lang: 'en-US', + // ... + }, + '/zh/': { + lang: 'zh-CN', + // ... + }, + // other languages + }, + // ... +} +``` + +So when a user accesses `/` or `/page.html`, they are automatically redirected to `/en/` `/en/page.html` and `/en/` `/en/page.html` based on current browser language. + +::: tip Customizing fallback behavior + +Sometimes, users may add more than one language to the system settings. By default, when a site supports a preferred language, but the page not exists for the preferred language, the plugin attempts to match the alternate language set by the user. + +If you don't need to fall back to the user's alternate language, but directly match the user's preferred language, set `localeFallback: false` in the plugin options. + +::: + +::: tip Customizing missing behavior + +Sometimes, when a user visits a page, the document does not yet contain the language version the user needs (a common case is that the current page has not been localized in the relevant language), so the plugin needs to perform a default action, which you can customize by `defaultBehavior` in the plugin options: + +- `"defaultLocale"`: Redirect to default language or first available language page (default behavior) +- `"homepage"`: redirect to the home page in the current language (only available if the document contains the user's language) +- `"404"`: Redirect to page 404 in current language (only available if the document contains the user's language) + +::: + +::: tip Customizing default locale path + +You can customize the default locale path by setting `defaultLocale` in the plugin options. By default, the plugin uses the first locale key in `locales` as the default language. + +::: + +### Automatically switch languages + +The plugin supports automatically switching the link to the multilingual page that the user needs according to the user's language preference when opening a multilingual document. In order to achieve this, you need to set `switchLocale` in the plugin options, which can be the following two values: + +- `direct`: switch directly to the user language preference page without asking +- `modal`: When the user's language preference is different from the current page language, show a modal asking whether to switch language + +### Customizing Locale Settings + +By default, the plugin generates a locale setting by reading `locale path` and `lang` from the site's `locales` option. Sometimes, you may want multiple languages to hit the same path, in which case you should set `localeConfig` in plugin options. + +For example, you might want all English users to match to `/en/` and Chinese Traditional users to `/zh/`, then you can set: + +```js +redirect({ + localeConfig: { + '/en/': ['en-US', 'en-UK', 'en'], + '/zh/': ['zh-CN', 'zh-TW', 'zh'], + }, +}) +``` + +### Redirecting Sites + +Sometimes you may change `base` or use new domain for your site, so you may want the original site automatically redirects to the new one. + +To solve this, the plugin provide `vp-redirect` cli. + +```shell +Usage: + $ vp-redirect generate [sourceDir] + +Options: + --hostname Hostname to redirect to (E.g.: https://new.example.com/) (default: /) + -c, --config Set path to config file + -o, --output Set the output directory (default: .vuepress/redirect) + --cache Set the directory of the cache files + -t, --temp Set the directory of the temporary files + --clean-cache Clean the cache files before generation + --clean-temp Clean the temporary files before generation + -h, --help Display this message +``` + +You need to pass in VuePress project source dir and also set the `hostname` option. The redirect helper cli will initialize your VuePress project to get pages, then generate and output the redirect html files to the output directory. + +By default, the plugin will output to `.vuepress/redirect` directory under source directory. And you should upload it to your original site to provide redirection. + +## Plugin Options + +### config + +- Type: `Record | ((app: App) => Record)` +- Details: Redirect map. +- Example: + + When base is set to `/base/`: + + - redirect `/base/foo.html` to `/base/bar.html` + - `/base/baz.html` to `https://example.com/qux.html`. + + ```js + redirect({ + config: { + '/foo.html': '/bar.html', + '/baz.html': 'https://example.com/qux.html', + }, + }) + ``` + + Redirect post folder to posts folder: + + ```js + redirect({ + hostname: 'https://example.com', + config: (app) => + Object.fromEntries( + app.pages + .filter(({ path }) => path.startsWith('/posts/')) + .map(({ path }) => [path.replace(/^\/posts\//, '/post/'), path]), + ), + }) + ``` + +### autoLocale + +- Type: `boolean` +- Default: `false` +- Details: Whether enable locales redirection. + +### switchLocale + +- Type: `"direct" | "modal" | false` +- Default: `false` +- Details: + + Whether switch to a new locale based on user preference. + + - `"direct"`: redirect to the new locale directly without asking + - `"modal"`: show a modal to let user choose whether to switch to the new locale + +### localeConfig + +- Type: `Record` + +- Details: Locale language config + +### localeFallback + +- Type: `boolean` +- Default: `true` +- Details: Whether fallback to other locales user defined + +### defaultBehavior + +- Type: `"defaultLocale" | "homepage" | "404"` +- Default: `"defaultLocale"` +- Details: Behavior when a locale version is not available for current link. + +### defaultLocale + +- Type: `string` +- Default: the first locale +- Details: Default locale path. + +## Frontmatter options + +### redirectFrom + +- Type: `string | string[]` +- Details: The link which this page redirects from. + +### redirectTo + +- Type: `string` +- Details: The link which this page redirects to. diff --git a/docs/zh/plugins/redirect.md b/docs/zh/plugins/redirect.md new file mode 100644 index 0000000000..97eefd5fc3 --- /dev/null +++ b/docs/zh/plugins/redirect.md @@ -0,0 +1,244 @@ +# redirect + + + +此插件提供页面与整站重定向功能。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-redirect@next +``` + +```ts +import { redirectPlugin } from '@vuepress/plugin-redirect' + +export default { + plugins: [ + redirectPlugin({ + // 配置项 + }), + ], +} +``` + +### 设置重定向 + +如果你改动了已有页面的地址,你可以在 Frontmatter 中使用 `redirectFrom` 选项设置重定向到此页面的地址,这样可以保证用户在访问旧链接时重定向到新的地址。 + +如果你需要将已有的页面重定向到新的页面,可以在 Frontmatter 中使用 `redirectTo` 选项设置需要重定向到的地址。这样该页面会在访问时重定向到新的地址。 + +你还可以通过插件选项中的 `config` 设置一个重定向映射,详见 [config](#config)。 + +### 自动多语言 + +插件可以根据用户的语言首选项,自动将无多语言链接重定向到用户需要的多语言页面。为了实现这一点,你需要留空默认的语言目录 (`/`),并在插件选项中设置 `autoLocale: true`。插件会自动根据用户语言跳转到对应的语言页面。 + +也就是你需要设置以下目录结构: + +``` +. +├── en +│ ├── ... +│ ├── page.md +│ └── README.md +├── zh +│ ├── ... +│ ├── page.md +│ └── README.md +└── other_languages + ├── ... + ├── page.md + └── README.md +``` + +并将主题选项的 locales 设置为: + +```js +export default { + locales: { + '/en/': { + lang: 'en-US', + // ... + }, + '/zh/': { + lang: 'zh-CN', + // ... + }, + // other languages + }, + // ... +} +``` + +这样当用户访问 `/` 或 `/page.html` 时,他们会自动根据当前浏览器语言重定向到 `/en/` `/en/page.html` 与 `/zh/` `/zh/page.html`。 + +::: tip 自定义回退行为 + +有些时候,用户可能会在系统设置中添加多个语言。默认情况下,在站点支持首选语言,但首选语言不存在相应页面时,插件会尝试匹配用户设置的备用语言。 + +如果不需要回退到用户备用语言,而直接匹配用户首选语言,请在插件选项中设置 `localeFallback: false`。 + +::: + +::: tip 自定义缺失行为 + +有些时候,当用户访问一个页面时,文档尚未包含用户需要的语言版本 (一个普遍的情况是当前页面尚未完成相关语言的本地化),这样插件需要做出默认行为,你可以通过插件选项中的 `defaultBehavior` 定制它: + +- `"defaultLocale"`: 重定向到默认语言或首个可用语言页面 (默认行为) +- `"homepage"`: 重定向到当前语言的主页 (仅在文档包含用户语言时可用) +- `"404"`: 重定向到当前语言的 404 页 (仅在文档包含用户语言时可用) + +::: + +::: tip 自定义默认路径 + +你可以通过设置插件选项中的 `defaultLocale` 来自定义默认路径。默认情况下,插件会使用 `locales` 中的第一个键名作为默认路径。 + +::: + +### 自动切换语言 + +插件支持在多语言文档中,自动根据用户语言首选项,将链接切换到用户需要的多语言页面。为了实现这一点,你需要在插件选项中设置 `switchLocale`,它可以是以下两个值: + +- `direct`: 直接切换到用户语言首选项页面,而不询问 +- `modal`: 在用户语言首选项与当前页面语言不同时,弹出一个对话框询问用户是否切换语言 + +### 自定义多语言配置 + +默认情况下,插件会从站点的多语言配置 `locales` 选项中,读取 `语言路径` 和 `lang` 生成多语言配置。有些时候,你可能希望多个语言命中同一个路径,这种情况下,你应该设置插件的 `localeConfig` 选项。 + +比如,你可能希望所有英文用户都匹配到 `/en/`,并将繁体中文用户匹配到 `/zh/` 中,那么你可以设置: + +```js +redirect({ + localeConfig: { + '/en/': ['en-US', 'en-UK', 'en'], + '/zh/': ['zh-CN', 'zh-TW', 'zh'], + }, +}) +``` + +### 重定向站点 + +有时你可能会更改 `base` 或为你的站点使用新域名,因此你可能希望原始站点自动重定向到新站点。 + +为了解决这个问题,插件提供了 `vp-redirect` 脚手架。 + +```shell +使用: + $ vp-redirect generate [源文件夹] + +Options: + --hostname 重定向到的域名 (例如: https://new.example.com/) (默认: /) + -c, --config 设置配置文件路径 + -o, --output 设置输出目录 (默认: .vuepress/redirect) + --cache 设置缓存文件的目录 + -t, --temp 设置临时文件的目录 + --clean-cache 生成前清理缓存文件 + --clean-temp 生成前清理临时文件 + -h, --help 显示此消息 +``` + +你需要传入 VuePress 项目源目录并设置 `hostname` 选项。重定向助手脚手架将初始化你的 VuePress 项目以获取页面,然后在输出目录生成重定向 html 文件。 + +默认情况下,插件将输出到源文件夹下的 `.vuepress/redirect` 目录。你应该将其上传到你的原始站点以提供重定向。 + +## 选项 + +### config + +- 类型:`Record | ((app: App) => Record)` +- 详情 + + 页面重定向映射。 + + 可直接传入对象或传入参数为 `App` 的函数返回值一个对象。 + + 每个键名必须是一个绝对路径,代表重定向的源页面地址。 + + 每个键值是重定向的目标地址,可以是绝对路径或完整路径。 + +- 示例: + + 当 base 为 `/base/`时: + + - 将 `/base/foo.html` 重定向到 `/base/bar.html` + - 将 `/base/baz.html` 重定向到 `https://example.com/qux.html`。 + + ```js + redirect({ + config: { + '/foo.html': '/bar.html', + '/baz.html': 'https://example.com/qux.html', + }, + }) + ``` + + 将 post 文件夹的路径重定向到 posts 文件夹 + + ```js + redirect({ + hostname: 'https://example.com', + config: (app) => + Object.fromEntries( + app.pages + .filter(({ path }) => path.startsWith('/posts/')) + .map(({ path }) => [path.replace(/^\/posts\//, '/post/'), path]), + ), + }) + ``` + +### autoLocale + +- 类型:`boolean` +- 默认值: `false` +- 详情: 是否启用语言重定向 +- 参考: + - [指南 → 重定向语言](./guide.md#重定向语言) + +### switchLocale + +- 类型:`"direct" | "modal" | false` +- 默认值: `false` +- 详情: + + 是否根据用户偏好切换到新的语言环境。 + + - `"direct"`: 直接重定向到新的语言环境而不询问 + - `"modal"`: 显示一个模式让用户选择是否切换到新的语言环境 + +### localeConfig + +- 类型:`Record` +- 详情:多语言语言配置 + +### localeFallback + +- 类型:`boolean` +- 默认值: `true` +- 详情:是否回退到用户定义的其他语言 + +### defaultBehavior + +- 类型:`"defaultLocale" | "homepage" | "404"` +- 默认值: `"defaultLocale"` +- 详情:当前链接没有可用的语言版本时的行为 + +### defaultLocale + +- 类型:`string` +- 默认值: 首个语言路径 +- 详情:默认语言路径 + +## Frontmatter + +### redirectFrom + +- 类型:`string | string[]` +- 详情:重定向到该页面的地址。 + +### redirectTo + +- 类型:`string` +- 详情:该页面重定向到的地址。 diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 75dfa14b3d..712a171c0d 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -3,6 +3,7 @@ import { viteBundler } from '@vuepress/bundler-vite' import { webpackBundler } from '@vuepress/bundler-webpack' import { copyrightPlugin } from '@vuepress/plugin-copyright' import { feedPlugin } from '@vuepress/plugin-feed' +import { redirectPlugin } from '@vuepress/plugin-redirect' import { defaultTheme } from '@vuepress/theme-default' import { defineUserConfig } from 'vuepress/cli' import type { UserConfig } from 'vuepress/cli' @@ -94,5 +95,11 @@ export default defineUserConfig({ json: true, rss: true, }), + redirectPlugin({ + config: { + '/redirect/config.html': '/redirect/final.html', + '/redirect/': '/redirect/final.html', + }, + }), ], }) as UserConfig diff --git a/e2e/docs/redirect/final.md b/e2e/docs/redirect/final.md new file mode 100644 index 0000000000..ed11956349 --- /dev/null +++ b/e2e/docs/redirect/final.md @@ -0,0 +1,5 @@ +--- +redirectFrom: /redirect/from.md +--- + +# Final diff --git a/e2e/docs/redirect/to.md b/e2e/docs/redirect/to.md new file mode 100644 index 0000000000..d937c74201 --- /dev/null +++ b/e2e/docs/redirect/to.md @@ -0,0 +1,5 @@ +--- +redirectTo: /redirect/final.md +--- + +# Redirect to diff --git a/e2e/package.json b/e2e/package.json index b8b20579b0..8f319eba03 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -20,6 +20,7 @@ "@vuepress/client": "2.0.0-rc.2", "@vuepress/plugin-copyright": "workspace:*", "@vuepress/plugin-feed": "workspace:*", + "@vuepress/plugin-redirect": "workspace:*", "@vuepress/theme-default": "workspace:*", "sass": "^1.70.0", "sass-loader": "^14.1.0", diff --git a/e2e/tests/plugin-redirect/redirect.cy.ts b/e2e/tests/plugin-redirect/redirect.cy.ts new file mode 100644 index 0000000000..7cb9acaae6 --- /dev/null +++ b/e2e/tests/plugin-redirect/redirect.cy.ts @@ -0,0 +1,25 @@ +describe('redirect', () => { + const BASE = Cypress.env('E2E_BASE') + + it('frontmatter redirectFrom', () => { + cy.visit('/redirect/from.html') + + cy.location('pathname').should('eq', `${BASE}redirect/final.html`) + }) + + it('frontmatter redirectTo', () => { + cy.visit('/redirect/to.html') + + cy.location('pathname').should('eq', `${BASE}redirect/final.html`) + }) + + it('config', () => { + cy.visit('/redirect/config.html') + + cy.location('pathname').should('eq', `${BASE}redirect/final.html`) + + cy.visit('/redirect/') + + cy.location('pathname').should('eq', `${BASE}redirect/final.html`) + }) +}) diff --git a/plugins/plugin-redirect/package.json b/plugins/plugin-redirect/package.json new file mode 100644 index 0000000000..9460a886cb --- /dev/null +++ b/plugins/plugin-redirect/package.json @@ -0,0 +1,57 @@ +{ + "name": "@vuepress/plugin-redirect", + "version": "2.0.0-rc.0", + "description": "VuePress plugin - redirect", + "keywords": [ + "vuepress-plugin", + "vuepress", + "plugin", + "redirect" + ], + "homepage": "https://ecosystem.vuejs.press/plugins/redirect.html", + "bugs": { + "url": "https://github.com/vuepress/ecosystem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuepress/ecosystem.git", + "directory": "plugins/plugin-redirect" + }, + "license": "MIT", + "author": { + "name": "Mr.Hope", + "email": "mister-hope@outlook.com", + "url": "https://mister-hope.com" + }, + "type": "module", + "exports": { + ".": "./lib/node/index.js", + "./package.json": "./package.json" + }, + "main": "./lib/node/index.js", + "types": "./lib/node/index.d.ts", + "bin": { + "vp-redirect": "./lib/cli/index.js" + }, + "files": [ + "lib" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "clean": "rimraf --glob ./lib ./*.tsbuildinfo", + "style": "sass src:lib --style=compressed --no-source-map" + }, + "dependencies": { + "@vuepress/helper": "workspace:*", + "@vueuse/core": "^10.7.2", + "cac": "^6.7.14", + "vue": "^3.4.15", + "vue-router": "^4.2.5" + }, + "peerDependencies": { + "vuepress": "2.0.0-rc.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/plugin-redirect/src/cli/index.ts b/plugins/plugin-redirect/src/cli/index.ts new file mode 100644 index 0000000000..637f36fa6c --- /dev/null +++ b/plugins/plugin-redirect/src/cli/index.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env node +import { createRequire } from 'node:module' +import { removeEndingSlash, removeLeadingSlash } from '@vuepress/helper' +import { cac } from 'cac' +import { + loadUserConfig, + resolveAppConfig, + resolveCliAppConfig, + resolveUserConfigConventionalPath, + transformUserConfigToPlugin, +} from 'vuepress/cli' +import { createBuildApp } from 'vuepress/core' +import { fs, logger, path } from 'vuepress/utils' +import { getRedirectHTML } from '../node/generate/getRedirectHTML.js' + +interface RedirectCommandOptions { + hostname: string + output?: string + config?: string + cache: string + temp?: string + cleanCache?: boolean + cleanTemp?: boolean +} + +const require = createRequire(import.meta.url) + +const cli = cac('vp-redirect') +const { version } = require('@vuepress/plugin-redirect/package.json') as { + version: string +} + +cli + .command( + 'generate [source-dir]', + 'Generate redirect site using VuePress project under source folder', + ) + .option( + '--hostname ', + 'Hostname to redirect to (E.g.: https://new.example.com/)', + { default: '/' }, + ) + .option('-c, --config ', 'Set path to config file') + .option( + '-o, --output ', + 'Set the output directory (default: .vuepress/redirect)', + ) + .option('--cache ', 'Set the directory of the cache files') + .option('-t, --temp ', 'Set the directory of the temporary files') + .option('--clean-cache', 'Clean the cache files before generation') + .option('--clean-temp', 'Clean the temporary files before generation') + .action(async (sourceDir: string, commandOptions: RedirectCommandOptions) => { + if (!sourceDir) return cli.outputHelp() + + // ensure NODE_ENV is set + process.env.NODE_ENV ??= 'production' + + // resolve app config from cli options + const cliAppConfig = resolveCliAppConfig(sourceDir, {}) + + // resolve user config file + const userConfigPath = resolveUserConfigConventionalPath( + cliAppConfig.source, + ) + + const { userConfig } = await loadUserConfig(userConfigPath) + + // resolve the final app config to use + const appConfig = resolveAppConfig({ + defaultAppConfig: {}, + cliAppConfig, + userConfig, + }) + + if (appConfig === null) return + + // create vuepress app + const app = createBuildApp(appConfig) + + // use user-config plugin + app.use(transformUserConfigToPlugin(userConfig, cliAppConfig.source)) + + // clean temp and cache + if (commandOptions.cleanTemp === true) { + logger.info('Cleaning temp...') + await fs.remove(app.dir.temp()) + } + if (commandOptions.cleanCache === true) { + logger.info('Cleaning cache...') + await fs.remove(app.dir.cache()) + } + + const outputFolder = commandOptions.output + ? path.join(process.cwd(), commandOptions.output) + : path.join(app.dir.source(), '.vuepress', 'redirect') + + // empty output directory + await fs.emptyDir(outputFolder) + + // initialize vuepress app to get pages + logger.info('Initializing VuePress and preparing data...') + + await app.init() + + // redirect all pages + + // initialize vuepress app to get pages + logger.info('Generating redirect pages...') + + await Promise.all( + app.pages.map((page) => { + const redirectUrl = `${removeEndingSlash(commandOptions.hostname)}${ + app.options.base + }${removeLeadingSlash(page.path)}` + const destLocation = path.join( + outputFolder, + removeLeadingSlash(page.path.replace(/\/$/, '/index.html')), + ) + + return fs + .ensureDir(path.dirname(destLocation)) + .then(() => fs.writeFile(destLocation, getRedirectHTML(redirectUrl))) + }), + ) + }) + +cli.command('').action(() => cli.outputHelp()) + +cli.help() +cli.version(version) + +cli.parse() diff --git a/plugins/plugin-redirect/src/client/components/LanguageSwitch.ts b/plugins/plugin-redirect/src/client/components/LanguageSwitch.ts new file mode 100644 index 0000000000..2ecf27532c --- /dev/null +++ b/plugins/plugin-redirect/src/client/components/LanguageSwitch.ts @@ -0,0 +1,167 @@ +import { + usePreferredLanguages, + useScrollLock, + useSessionStorage, +} from '@vueuse/core' +import type { VNode } from 'vue' +import { + computed, + defineComponent, + h, + onMounted, + onUnmounted, + ref, + TransitionGroup, + watch, +} from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useRouteLocale } from 'vuepress/client' +import type { RedirectPluginLocaleConfig } from '../../shared/locales.js' +import { redirectLocaleConfig, redirectLocaleEntries } from '../define.js' + +import '../styles/language-switch.css' + +declare const __REDIRECT_LOCALES__: RedirectPluginLocaleConfig + +const redirectLocales = __REDIRECT_LOCALES__ +const { switchLocale } = redirectLocaleConfig + +interface LocaleInfo { + lang: string + localePath: string +} + +const REDIRECT_LOCALE_STORAGE = useSessionStorage>( + 'VUEPRESS_REDIRECT_LOCALES', + {}, +) + +export default defineComponent({ + name: 'LanguageSwitch', + + setup() { + const languages = usePreferredLanguages() + const route = useRoute() + const router = useRouter() + const routeLocale = useRouteLocale() + + const showModal = ref(false) + + const info = computed(() => { + if (redirectLocaleEntries.some(([key]) => routeLocale.value === key)) + for (const language of languages.value) + for (const [localePath, langs] of redirectLocaleEntries) + if (langs.includes(language)) { + if (localePath === routeLocale.value) return null + + return { + lang: language, + localePath, + } + } + + return null + }) + + const locale = computed(() => { + if (info.value) { + const { lang, localePath } = info.value + const locales = [ + redirectLocales[routeLocale.value], + redirectLocales[localePath], + ] + + return { + hint: locales.map(({ hint }) => hint.replace('$1', lang)), + switch: locales + .map(({ switch: switchText }) => switchText.replace('$1', lang)) + .join(' / '), + cancel: locales.map(({ cancel }) => cancel).join(' / '), + } + } + + return null + }) + + const targetRoute = computed(() => + info.value + ? route.path.replace(routeLocale.value, info.value.localePath) + : null, + ) + + const updateStatus = (): void => { + REDIRECT_LOCALE_STORAGE.value[routeLocale.value] = true + showModal.value = false + } + + onMounted(() => { + const isLocked = useScrollLock(document.body) + + if (!REDIRECT_LOCALE_STORAGE.value[routeLocale.value]) + if (info.value) + if (switchLocale === 'direct') router.replace(targetRoute.value!) + else if (switchLocale === 'modal') showModal.value = true + else showModal.value = false + else showModal.value = false + + watch( + showModal, + (value) => { + isLocked.value = value + }, + { immediate: true }, + ) + + onUnmounted(() => { + isLocked.value = false + }) + }) + + return (): VNode | null => + showModal.value + ? h(TransitionGroup, { name: 'lang-modal-fade' }, () => + showModal.value + ? h( + 'div', + { key: 'mask', class: 'lang-modal-mask' }, + h( + 'div', + { + key: 'popup', + class: 'lang-modal-wrapper', + }, + [ + h( + 'div', + { class: 'lang-modal-content' }, + locale.value?.hint.map((text) => h('p', text)), + ), + h( + 'button', + { + type: 'button', + class: 'lang-modal-action primary', + onClick: () => { + updateStatus() + router.replace(targetRoute.value!) + }, + }, + locale.value?.switch, + ), + h( + 'button', + { + type: 'button', + class: 'lang-modal-action', + onClick: () => updateStatus(), + }, + locale.value?.cancel, + ), + ], + ), + ) + : null, + ) + : null + }, +}) diff --git a/plugins/plugin-redirect/src/client/composables/index.ts b/plugins/plugin-redirect/src/client/composables/index.ts new file mode 100644 index 0000000000..1687eb4543 --- /dev/null +++ b/plugins/plugin-redirect/src/client/composables/index.ts @@ -0,0 +1 @@ +export * from './setupDevServerRedirect.js' diff --git a/plugins/plugin-redirect/src/client/composables/setupDevServerRedirect.ts b/plugins/plugin-redirect/src/client/composables/setupDevServerRedirect.ts new file mode 100644 index 0000000000..30f551a188 --- /dev/null +++ b/plugins/plugin-redirect/src/client/composables/setupDevServerRedirect.ts @@ -0,0 +1,109 @@ +import { redirectMap } from '@temp/redirect/map.js' +import { entries, isLinkHttp } from '@vuepress/helper/client' +import { usePreferredLanguages } from '@vueuse/core' +import { computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useRouteLocale } from 'vuepress/client' +import { normalizePath } from '../../shared/index.js' +import { redirectLocaleConfig, redirectLocaleEntries } from '../define.js' + +const { + autoLocale, + defaultBehavior, + defaultLocale: defaultLocalePath, + localeFallback, +} = redirectLocaleConfig + +export const setupDevServerRedirect = (): void => { + const languages = usePreferredLanguages() + const route = useRoute() + const router = useRouter() + const routeLocale = useRouteLocale() + + const isRootLocale = computed(() => routeLocale.value === '/') + + const handleLocaleRedirect = (): void => { + const routes = router.getRoutes() + const defaultLocale = + defaultLocalePath && + routes.some( + ({ path }) => path === route.path.replace('/', defaultLocalePath), + ) + ? defaultLocalePath + : routes.find( + ({ path }) => + route.path.split('/').length >= 3 && + path === route.path.replace(/^\/[^/]+\//, '/'), + )?.path + + let matchedLocalePath: string | null = null + + // get matched locale + // eslint-disable-next-line no-labels + findLanguage: for (const lang of languages.value) + for (const [localePath, langs] of redirectLocaleEntries) + if (langs.includes(lang)) { + if ( + localeFallback && + routes.every(({ path }) => path !== route.path.replace('/', path)) + ) + continue + + matchedLocalePath = localePath + // eslint-disable-next-line no-labels + break findLanguage + } + + // default link + const defaultRoute = defaultLocale + ? route.fullPath.replace('/', defaultLocale) + : null + + // a locale matches + if (matchedLocalePath) { + const hasLocalePage = routes.some( + ({ path }) => route.path.replace('/', matchedLocalePath!) === path, + ) + const localeRoute = route.fullPath.replace('/', matchedLocalePath) + + const routePath = + // the locale page exists + hasLocalePage + ? localeRoute + : // the page does not exist + defaultBehavior === 'homepage' + ? // locale homepage + matchedLocalePath + : defaultBehavior === 'defaultLocale' && defaultRoute + ? // default locale page + defaultRoute + : // as is to get a 404 page of that locale + localeRoute + + router.replace(routePath) + } + // we have a default page + else if (defaultRoute) { + router.replace(defaultRoute) + } else if (route.path !== '/404.html') { + router.replace('/404.html') + } + } + + watch( + () => route.path, + (path) => { + // handle redirects + for (const [from, to] of entries(redirectMap)) + if (normalizePath(path.toLowerCase()) === from.toLowerCase()) { + if (isLinkHttp(to)) window.open(to) + else router.replace(to) + + return + } + + if (autoLocale && isRootLocale.value) handleLocaleRedirect() + }, + { immediate: true }, + ) +} diff --git a/plugins/plugin-redirect/src/client/config.ts b/plugins/plugin-redirect/src/client/config.ts new file mode 100644 index 0000000000..2d706fac90 --- /dev/null +++ b/plugins/plugin-redirect/src/client/config.ts @@ -0,0 +1,13 @@ +import type { ClientConfig } from 'vuepress/client' +import { defineClientConfig } from 'vuepress/client' +import LanguageSwitch from './components/LanguageSwitch.js' +import { setupDevServerRedirect } from './composables/setupDevServerRedirect.js' + +declare const __REDIRECT_LOCALE_SWITCH__: boolean + +export default defineClientConfig({ + setup() { + if (__VUEPRESS_DEV__) setupDevServerRedirect() + }, + rootComponents: __REDIRECT_LOCALE_SWITCH__ ? [LanguageSwitch] : [], +}) as ClientConfig diff --git a/plugins/plugin-redirect/src/client/define.ts b/plugins/plugin-redirect/src/client/define.ts new file mode 100644 index 0000000000..ceba09be36 --- /dev/null +++ b/plugins/plugin-redirect/src/client/define.ts @@ -0,0 +1,7 @@ +import { entries } from '@vuepress/helper/client' +import type { RedirectLocaleConfig } from '../shared/index.js' + +declare const __REDIRECT_LOCALE_CONFIG__: RedirectLocaleConfig + +export const redirectLocaleConfig = __REDIRECT_LOCALE_CONFIG__ +export const redirectLocaleEntries = entries(redirectLocaleConfig.localeConfig) diff --git a/plugins/plugin-redirect/src/client/styles/language-switch.scss b/plugins/plugin-redirect/src/client/styles/language-switch.scss new file mode 100644 index 0000000000..b2fa08449e --- /dev/null +++ b/plugins/plugin-redirect/src/client/styles/language-switch.scss @@ -0,0 +1,67 @@ +.lang-modal-fade-enter-active, +.lang-modal-fade-leave-active { + transition: opacity 0.5s; +} + +.lang-modal-fade-enter, +.lang-modal-fade-leave-to { + opacity: 0; +} + +.lang-modal-mask { + position: fixed; + inset: 0; + z-index: 1499; + + display: flex; + align-items: center; + justify-content: center; + + backdrop-filter: blur(10px); + + @media print { + display: none; + } +} + +.lang-modal-wrapper { + position: relative; + z-index: 1500; + + overflow: hidden; + + max-width: 80vw; + padding: 1rem 2rem; + border-radius: 8px; + + background: var(--vp-bg); + box-shadow: 0 2px 6px 0 var(--card-shadow); +} + +.lang-modal-action { + display: block; + + width: 100%; + margin: 1rem 0; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 8px; + + background-color: var(--vp-bglt); + color: var(--vp-c); + + cursor: pointer; + + &:hover { + background-color: var(--vp-bgl); + } + + &.primary { + background-color: var(--vp-tc); + color: var(--white); + + &:hover { + background-color: var(--vp-tcl); + } + } +} diff --git a/plugins/plugin-redirect/src/node/ensureRootHomePage.ts b/plugins/plugin-redirect/src/node/ensureRootHomePage.ts new file mode 100644 index 0000000000..188918bef6 --- /dev/null +++ b/plugins/plugin-redirect/src/node/ensureRootHomePage.ts @@ -0,0 +1,88 @@ +import { removeEndingSlash } from '@vuepress/helper' +import type { App } from 'vuepress/core' +import { createPage } from 'vuepress/core' +import type { RedirectLocaleConfig } from '../shared/index.js' + +export const ensureRootHomePage = async ( + app: App, + localeOptions: RedirectLocaleConfig, +): Promise => { + const { + options: { base }, + pages, + } = app + + if ( + // root homepage not exists + pages.every(({ path }) => path !== '/') + ) { + const availableLocales = pages + .filter(({ pathLocale, path }) => pathLocale === path) + .map(({ pathLocale }) => pathLocale) + + pages.push( + await createPage(app, { + path: '/', + frontmatter: { title: 'Home' }, + // set markdown content + content: `\ +Redirecting to the correct locale... + + +`, + }), + ) + } +} diff --git a/plugins/plugin-redirect/src/node/frontmatter.ts b/plugins/plugin-redirect/src/node/frontmatter.ts new file mode 100644 index 0000000000..57b0f1dc3a --- /dev/null +++ b/plugins/plugin-redirect/src/node/frontmatter.ts @@ -0,0 +1,4 @@ +export interface RedirectPluginFrontmatterOption { + redirectFrom?: string | string[] + redirectTo?: string +} diff --git a/plugins/plugin-redirect/src/node/generate/generateAutoLocaleRedirectFiles.ts b/plugins/plugin-redirect/src/node/generate/generateAutoLocaleRedirectFiles.ts new file mode 100644 index 0000000000..48cfd245c6 --- /dev/null +++ b/plugins/plugin-redirect/src/node/generate/generateAutoLocaleRedirectFiles.ts @@ -0,0 +1,45 @@ +import { entries, removeLeadingSlash } from '@vuepress/helper' +import type { App } from 'vuepress/core' +import { fs, path } from 'vuepress/utils' +import type { RedirectLocaleConfig } from '../../shared/index.js' +import { logger } from '../logger.js' +import { getLocaleRedirectHTML } from './getLocaleRedirectHTML.js' + +export const generateAutoLocaleRedirectFiles = async ( + { dir, options, pages }: App, + localeOptions: RedirectLocaleConfig, +): Promise => { + const rootPaths = pages + .filter(({ pathLocale }) => pathLocale === '/') + .map(({ path }) => path) + const localeRedirectMap: Record = {} + + pages + .filter(({ pathLocale }) => pathLocale !== '/') + .forEach(({ path, pathLocale }) => { + const rootPath = path + .replace(pathLocale, '/') + .replace(/\/$/, '/index.html') + + if (!rootPaths.includes(rootPath)) + (localeRedirectMap[rootPath] ??= []).push(pathLocale) + }) + + const { succeed } = logger.load('Generating locale redirect files') + + await Promise.all( + entries(localeRedirectMap).map(async ([rootPath, availableLocales]) => { + const filePath = dir.dest(removeLeadingSlash(rootPath)) + + if (!fs.existsSync(filePath)) { + await fs.ensureDir(path.dirname(filePath)) + await fs.writeFile( + filePath, + getLocaleRedirectHTML(localeOptions, availableLocales, options.base), + ) + } + }), + ) + + succeed() +} diff --git a/plugins/plugin-redirect/src/node/generate/generateRedirectFiles.ts b/plugins/plugin-redirect/src/node/generate/generateRedirectFiles.ts new file mode 100644 index 0000000000..55e23d2db5 --- /dev/null +++ b/plugins/plugin-redirect/src/node/generate/generateRedirectFiles.ts @@ -0,0 +1,40 @@ +import { + entries, + isLinkAbsolute, + isLinkHttp, + removeEndingSlash, + removeLeadingSlash, +} from '@vuepress/helper' +import type { App } from 'vuepress/core' +import { fs, path } from 'vuepress/utils' +import { logger } from '../logger.js' +import { getRedirectHTML } from './getRedirectHTML.js' + +export const generateRedirectFiles = async ( + { dir, options }: App, + config: Record, + hostname = '', +): Promise => { + const resolvedHostname = hostname + ? removeEndingSlash(isLinkHttp(hostname) ? hostname : `https://${hostname}`) + : '' + + const { succeed } = logger.load('Generating redirect files') + + await Promise.all( + entries(config).map(async ([from, to]) => { + const filePath = dir.dest(removeLeadingSlash(from)) + + if (!fs.existsSync(filePath)) { + const redirectUrl = isLinkAbsolute(to) + ? `${resolvedHostname}${options.base}${removeLeadingSlash(to)}` + : to + + await fs.ensureDir(path.dirname(filePath)) + await fs.writeFile(filePath, getRedirectHTML(redirectUrl)) + } + }), + ) + + succeed() +} diff --git a/plugins/plugin-redirect/src/node/generate/getLocaleRedirectHTML.ts b/plugins/plugin-redirect/src/node/generate/getLocaleRedirectHTML.ts new file mode 100644 index 0000000000..137706a421 --- /dev/null +++ b/plugins/plugin-redirect/src/node/generate/getLocaleRedirectHTML.ts @@ -0,0 +1,98 @@ +import { removeEndingSlash } from '@vuepress/helper' +import type { RedirectLocaleConfig } from '../../shared/index.js' + +export const getLocaleRedirectHTML = ( + { + localeConfig, + defaultBehavior, + defaultLocale, + localeFallback, + }: RedirectLocaleConfig, + availableLocales: string[], + base: string, +): string => ` + + + + + Redirecting... + + + +

Redirecting...

+ + +` diff --git a/plugins/plugin-redirect/src/node/generate/getRedirectHTML.ts b/plugins/plugin-redirect/src/node/generate/getRedirectHTML.ts new file mode 100644 index 0000000000..bc1ac85bbf --- /dev/null +++ b/plugins/plugin-redirect/src/node/generate/getRedirectHTML.ts @@ -0,0 +1,18 @@ +export const getRedirectHTML = (redirectUrl: string): string => ` + + + + + + + Redirecting... + + + +

Redirecting...

+ + +` diff --git a/plugins/plugin-redirect/src/node/generate/index.ts b/plugins/plugin-redirect/src/node/generate/index.ts new file mode 100644 index 0000000000..e68410870d --- /dev/null +++ b/plugins/plugin-redirect/src/node/generate/index.ts @@ -0,0 +1,2 @@ +export * from './generateAutoLocaleRedirectFiles.js' +export * from './generateRedirectFiles.js' diff --git a/plugins/plugin-redirect/src/node/getRedirectLocaleConfig.ts b/plugins/plugin-redirect/src/node/getRedirectLocaleConfig.ts new file mode 100644 index 0000000000..4445d6f062 --- /dev/null +++ b/plugins/plugin-redirect/src/node/getRedirectLocaleConfig.ts @@ -0,0 +1,64 @@ +import { + deepAssign, + entries, + fromEntries, + isArray, + isPlainObject, + keys, +} from '@vuepress/helper' +import type { App } from 'vuepress/core' +import { colors } from 'vuepress/utils' +import type { RedirectLocaleConfig } from '../shared/index.js' +import { logger } from './logger.js' +import type { RedirectOptions } from './options.js' + +const AVAILABLE_FALLBACK = ['defaultLocale', 'homepage', '404'] as const + +export const getRedirectLocaleConfig = ( + app: App, + options: RedirectOptions, +): RedirectLocaleConfig => { + const { locales } = app.options + + const localeConfig = deepAssign( + fromEntries( + entries(locales) + .filter(([key, { lang }]) => { + if (key === '/') return false + + if (!lang) { + logger.error( + `Missing ${colors.magenta( + 'lang', + )} option for locale "${key}", this locale will be ignored!`, + ) + + return false + } + + return true + }) + .map(([key, { lang }]) => [key, [lang!]]), + ), + isPlainObject(options.localeConfig) + ? entries(options.localeConfig).map(([routePath, lang]) => [ + routePath, + isArray(lang) ? lang : [lang], + ]) + : {}, + ) + const defaultLocale = options.defaultLocale || keys(localeConfig).pop()! + + return { + autoLocale: options.autoLocale ?? false, + switchLocale: options.switchLocale ?? false, + localeConfig, + defaultLocale, + localeFallback: options.localeFallback ?? true, + defaultBehavior: + options.defaultBehavior && + AVAILABLE_FALLBACK.includes(options.defaultBehavior) + ? options.defaultBehavior + : 'defaultLocale', + } +} diff --git a/plugins/plugin-redirect/src/node/getRedirectMap.ts b/plugins/plugin-redirect/src/node/getRedirectMap.ts new file mode 100644 index 0000000000..7ce06ef5b0 --- /dev/null +++ b/plugins/plugin-redirect/src/node/getRedirectMap.ts @@ -0,0 +1,50 @@ +import { + entries, + fromEntries, + isArray, + isFunction, + isPlainObject, +} from '@vuepress/helper' +import type { App, Page } from 'vuepress/core' +import { normalizePath } from '../shared/normalizePath.js' +import type { RedirectPluginFrontmatterOption } from './frontmatter.js' +import type { RedirectOptions } from './options.js' + +export const getRedirectMap = ( + app: App, + options: RedirectOptions, +): Record => { + const config = isFunction(options.config) + ? options.config(app) + : isPlainObject(options.config) + ? options.config + : {} + + return { + ...fromEntries( + ( + app.pages as Page< + Record, + RedirectPluginFrontmatterOption + >[] + ) + .map<[string, string][]>(({ frontmatter, path }) => + isArray(frontmatter.redirectFrom) + ? frontmatter.redirectFrom.map((from) => [ + normalizePath(from, true), + path, + ]) + : frontmatter.redirectFrom + ? [[normalizePath(frontmatter.redirectFrom, true), path]] + : [], + ) + .flat(), + ), + ...fromEntries( + entries(config).map(([from, to]) => [ + normalizePath(from, true), + normalizePath(to), + ]), + ), + } +} diff --git a/plugins/plugin-redirect/src/node/handleRedirectTo.ts b/plugins/plugin-redirect/src/node/handleRedirectTo.ts new file mode 100644 index 0000000000..7f1123d4e7 --- /dev/null +++ b/plugins/plugin-redirect/src/node/handleRedirectTo.ts @@ -0,0 +1,27 @@ +import { isLinkAbsolute, removeLeadingSlash } from '@vuepress/helper' +import type { App, Page } from 'vuepress/core' +import { normalizePath } from '../shared/normalizePath.js' +import type { RedirectPluginFrontmatterOption } from './frontmatter.js' + +export const handleRedirectTo = ({ frontmatter }: Page, app: App): void => { + const { base } = app.options + + const { redirectTo } = frontmatter as RedirectPluginFrontmatterOption + + if (redirectTo) { + const redirectUrl = normalizePath( + isLinkAbsolute(redirectTo) + ? `${base}${removeLeadingSlash(redirectTo)}` + : redirectTo, + ) + + ;(frontmatter.head ??= []).unshift([ + 'script', + {}, + `{\ +const anchor = window.location.hash.substring(1);\ +location.href=\`${redirectUrl}\${anchor? \`#\${anchor}\`: ""}\`;\ +}`, + ]) + } +} diff --git a/plugins/plugin-redirect/src/node/index.ts b/plugins/plugin-redirect/src/node/index.ts new file mode 100644 index 0000000000..9d5e9acd50 --- /dev/null +++ b/plugins/plugin-redirect/src/node/index.ts @@ -0,0 +1,3 @@ +export * from './frontmatter.js' +export * from './options.js' +export * from './redirectPlugin.js' diff --git a/plugins/plugin-redirect/src/node/locales.ts b/plugins/plugin-redirect/src/node/locales.ts new file mode 100644 index 0000000000..3ce0f8d4e9 --- /dev/null +++ b/plugins/plugin-redirect/src/node/locales.ts @@ -0,0 +1,151 @@ +import type { RedirectPluginLocaleConfig } from '../shared/index.js' + +/** Multi language config for redirect popup */ +export const redirectLocales: RedirectPluginLocaleConfig = { + '/en/': { + name: 'English', + hint: 'Your primary language is $1, do you want to switch to it?', + switch: 'Switch to $1', + cancel: 'Cancel', + }, + + '/zh/': { + name: '简体中文', + hint: '您的首选语言是 $1,是否切换到该语言?', + switch: '切换到 $1', + cancel: '取消', + }, + + '/zh-tw/': { + name: '繁體中文', + hint: '您的首選語言是 $1,是否切換到該語言?', + switch: '切換到 $1', + cancel: '取消', + }, + + '/de/': { + name: 'Deutsch', + hint: 'Ihre bevorzugte Sprache ist $1, möchten Sie zu dieser wechseln?', + switch: 'Zu $1 wechseln', + cancel: 'Abbrechen', + }, + + '/de-at/': { + name: 'Deutsch (Österreich)', + hint: 'Ihre bevorzugte Sprache ist $1, möchten Sie zu dieser wechseln?', + switch: 'Zu $1 wechseln', + cancel: 'Abbrechen', + }, + + '/vi/': { + name: 'Tiếng Việt', + hint: 'Ngôn ngữ chính của bạn là $1, bạn có muốn chuyển sang nó?', + switch: 'Chuyển sang $1', + cancel: 'Hủy', + }, + + '/uk/': { + name: 'Українська', + hint: 'Вашою основною мовою є $1, чи бажаєте ви переключитися на неї?', + switch: 'Переключитися на $1', + cancel: 'Скасувати', + }, + + '/ru/': { + name: 'Русский', + hint: 'Ваш основной язык - $1, вы хотите переключиться на него?', + switch: 'Переключиться на $1', + cancel: 'Отмена', + }, + + '/br/': { + name: 'Português (Brasil)', + hint: 'A língua principal é $1, deseja mudar para ela?', + switch: 'Mudar para $1', + cancel: 'Cancelar', + }, + + '/pl/': { + name: 'Polski', + hint: 'Twoim głównym językiem jest $1, czy chcesz przełączyć się na niego?', + switch: 'Przełącz na $1', + cancel: 'Anuluj', + }, + + '/sk/': { + name: 'Slovenčina', + hint: 'Vašou hlavnou jazykom je $1, chcete prepnúť naň?', + switch: 'Prepnúť na $1', + cancel: 'Zrušiť', + }, + + '/fr/': { + name: 'Français', + hint: 'Votre langue principale est $1, voulez-vous la changer ?', + switch: 'Changer pour $1', + cancel: 'Annuler', + }, + + '/tr/': { + name: 'Türkçe', + hint: 'Ana diliniz $1, ona geçmek ister misiniz?', + switch: "$1'e geç", + cancel: 'İptal', + }, + + '/fi/': { + name: 'Suomi', + hint: 'Pääkielenäsi on $1, haluatko vaihtaa siihen?', + switch: 'Vaihda $1:een', + cancel: 'Peruuta', + }, + + '/hu/': { + name: 'Magyar', + hint: 'A fő nyelvét $1, szeretné váltani?', + switch: 'Váltás $1', + cancel: 'Mégse', + }, + + '/id/': { + name: 'Bahasa Indonesia', + hint: 'Bahasa utama Anda adalah $1, apakah Anda ingin beralih ke sana?', + switch: 'Beralih ke $1', + cancel: 'Batal', + }, + + '/nl/': { + name: 'Nederlands', + hint: 'Uw primaire taal is $1, wilt u overschakelen?', + switch: 'Overschakelen naar $1', + cancel: 'Annuleren', + }, + + '/ja/': { + name: '日本語', + hint: 'あなたの主要な言語は $1 です。それに切り替えますか?', + switch: '$1 に切り替える', + cancel: 'キャンセル', + }, + + '/ko/': { + name: '한국어', + hint: '당신의 기본 언어는 $1입니다. 그것으로 전환 하시겠습니까?', + switch: '$1로 전환', + cancel: '취소', + }, + + '/es/': { + name: 'Español', + hint: 'Su idioma principal es $1, ¿desea cambiarlo?', + switch: 'Cambiar a $1', + cancel: 'Cancelar', + }, + + '/pt/': { + name: 'Português', + hint: 'Sua língua principal é $1, deseja mudar para ela?', + switch: 'Mudar para $1', + cancel: 'Cancelar', + }, +} diff --git a/plugins/plugin-redirect/src/node/logger.ts b/plugins/plugin-redirect/src/node/logger.ts new file mode 100644 index 0000000000..613f992dfb --- /dev/null +++ b/plugins/plugin-redirect/src/node/logger.ts @@ -0,0 +1,5 @@ +import { Logger } from '@vuepress/helper' + +export const PLUGIN_NAME = '@vuepress/plugin-redirect' + +export const logger = new Logger(PLUGIN_NAME) diff --git a/plugins/plugin-redirect/src/node/options.ts b/plugins/plugin-redirect/src/node/options.ts new file mode 100644 index 0000000000..8998a9162d --- /dev/null +++ b/plugins/plugin-redirect/src/node/options.ts @@ -0,0 +1,29 @@ +import type { App, LocaleConfig } from 'vuepress/core' +import type { + RedirectLocaleConfig, + RedirectPluginLocaleData, +} from '../shared/index.js' + +export interface RedirectOptions + extends Partial> { + /** + * Redirect mapping + * + * 重定向映射 + */ + config?: Record | ((app: App) => Record) + + /** + * Locale language config + * + * 多语言语言配置 + */ + localeConfig?: Record + + /** + * Locales config + * + * 多语言选项 + */ + locales?: LocaleConfig +} diff --git a/plugins/plugin-redirect/src/node/redirectPlugin.ts b/plugins/plugin-redirect/src/node/redirectPlugin.ts new file mode 100644 index 0000000000..d2f213e96b --- /dev/null +++ b/plugins/plugin-redirect/src/node/redirectPlugin.ts @@ -0,0 +1,76 @@ +import { addViteSsrNoExternal, getLocaleConfig } from '@vuepress/helper' +import type { PluginFunction } from 'vuepress/core' +import { getDirname, path } from 'vuepress/utils' +import { ensureRootHomePage } from './ensureRootHomePage.js' +import { + generateAutoLocaleRedirectFiles, + generateRedirectFiles, +} from './generate/index.js' +import { getRedirectLocaleConfig } from './getRedirectLocaleConfig.js' +import { getRedirectMap } from './getRedirectMap.js' +import { handleRedirectTo } from './handleRedirectTo.js' +import { redirectLocales } from './locales.js' +import { logger, PLUGIN_NAME } from './logger.js' +import type { RedirectOptions } from './options.js' + +const __dirname = getDirname(import.meta.url) + +export const redirectPlugin = + (options: RedirectOptions = {}): PluginFunction => + (app) => { + if (app.env.isDebug) logger.info('Options:', options) + + const redirectLocaleConfig = getRedirectLocaleConfig(app, options) + let redirectMap: Record + + return { + name: PLUGIN_NAME, + + define: { + __REDIRECT_LOCALE_CONFIG__: redirectLocaleConfig, + __REDIRECT_LOCALE_SWITCH__: Boolean(redirectLocaleConfig.switchLocale), + __REDIRECT_LOCALES__: getLocaleConfig({ + app, + name: 'redirect', + config: options.locales, + default: redirectLocales, + }), + }, + + extendsBundlerOptions: (bundlerOptions: unknown, app): void => { + addViteSsrNoExternal(bundlerOptions, app, '@vuepress/helper') + }, + + extendsPage: (page, app) => { + handleRedirectTo(page, app) + }, + + onInitialized: async (app): Promise => { + redirectMap = getRedirectMap(app, options) + + if (app.env.isDebug) logger.info('Redirect Map:', redirectMap) + + if (redirectLocaleConfig.autoLocale) + await ensureRootHomePage(app, redirectLocaleConfig) + }, + + onPrepared: async (app): Promise => { + await app.writeTemp( + 'redirect/map.js', + `\ +export const redirectMap = ${ + app.env.isDev ? JSON.stringify(redirectMap, null, 2) : '{}' + }; +`, + ) + }, + + onGenerated: async (app): Promise => { + await generateRedirectFiles(app, redirectMap) + if (redirectLocaleConfig.autoLocale) + await generateAutoLocaleRedirectFiles(app, redirectLocaleConfig) + }, + + clientConfigFile: path.join(__dirname, '../client/config.js'), + } + } diff --git a/plugins/plugin-redirect/src/shared/index.ts b/plugins/plugin-redirect/src/shared/index.ts new file mode 100644 index 0000000000..ab739754ee --- /dev/null +++ b/plugins/plugin-redirect/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './localeConfig.js' +export * from './locales.js' +export * from './normalizePath.js' diff --git a/plugins/plugin-redirect/src/shared/localeConfig.ts b/plugins/plugin-redirect/src/shared/localeConfig.ts new file mode 100644 index 0000000000..2b061af29b --- /dev/null +++ b/plugins/plugin-redirect/src/shared/localeConfig.ts @@ -0,0 +1,59 @@ +export interface RedirectLocaleConfig { + /** + * Whether enable locales redirection + * + * 是否启用语言重定向 + * + * @default false + */ + autoLocale: boolean + + /** + * Whether switch locales + * + * 是否启用重定向语言 + * + * @default false + */ + switchLocale: 'direct' | 'modal' | false + + /** + * Locale language config + * + * 多语言语言配置 + */ + localeConfig: Record + + /** + * Whether fallback to other locales user defined + * + * 是否回退到用户定义的其他语言 + * + * @default true + */ + localeFallback: boolean + + /** + * Behavior when a locale version is not available for current link + * + * @description `"homepage"` and `"404"` is only available when a locale is assigned to current language + * + * 当前链接没有可用的语言版本时的行为 + * + * @description 只有当语言分配给当前语言时,`"homepage"` 和 `"404"` 才可用 + * + * @default "defaultLocale" + */ + defaultBehavior: 'defaultLocale' | 'homepage' | '404' + + /** + * Default locale path + * + * @description the first locale will be used if absent + * + * 默认语言路径 + * + * @description 如果缺失,则使用第一个语言 + */ + defaultLocale: string +} diff --git a/plugins/plugin-redirect/src/shared/locales.ts b/plugins/plugin-redirect/src/shared/locales.ts new file mode 100644 index 0000000000..5d6c723e85 --- /dev/null +++ b/plugins/plugin-redirect/src/shared/locales.ts @@ -0,0 +1,30 @@ +import type { ExactLocaleConfig } from '@vuepress/helper/shared' + +export interface RedirectPluginLocaleData { + /** + * Language name + */ + name: string + + /** + * Switch hint + * + * 切换提示 + */ + hint: string + + /** + * Switch button text + */ + switch: string + + /** + * Cancel button text + * + * 取消按钮文字 + */ + cancel: string +} + +export type RedirectPluginLocaleConfig = + ExactLocaleConfig diff --git a/plugins/plugin-redirect/src/shared/normalizePath.ts b/plugins/plugin-redirect/src/shared/normalizePath.ts new file mode 100644 index 0000000000..8621c603b5 --- /dev/null +++ b/plugins/plugin-redirect/src/shared/normalizePath.ts @@ -0,0 +1,6 @@ +const HASH_REGEXP = /#.*$/u + +export const normalizePath = (path: string, removeHash = false): string => + (removeHash ? path.replace(HASH_REGEXP, '') : path) + .replace(/\/(?:README\.md)?$/i, '/index.html') + .replace(/(?:\.(?:md|html))?$/, '.html') diff --git a/plugins/plugin-redirect/src/shims-redirectMap.d.ts b/plugins/plugin-redirect/src/shims-redirectMap.d.ts new file mode 100644 index 0000000000..eb09a4bb5a --- /dev/null +++ b/plugins/plugin-redirect/src/shims-redirectMap.d.ts @@ -0,0 +1,3 @@ +declare module '@temp/redirect/map.js' { + export const redirectMap: Record +} diff --git a/plugins/plugin-redirect/tests/shared/normalizePath.spec.ts b/plugins/plugin-redirect/tests/shared/normalizePath.spec.ts new file mode 100644 index 0000000000..05c3232c41 --- /dev/null +++ b/plugins/plugin-redirect/tests/shared/normalizePath.spec.ts @@ -0,0 +1,62 @@ +import { expect, it } from 'vitest' +import { normalizePath } from '../../src/shared/normalizePath.js' + +it('Should normalize path', () => { + const testCases = [ + ['/', '/index.html'], + ['/index', '/index.html'], + ['/index.md', '/index.html'], + ['/index.html', '/index.html'], + ['/foo/', '/foo/index.html'], + ['/foo/bar/', '/foo/bar/index.html'], + ['/foo/bar/index', '/foo/bar/index.html'], + ['/foo/bar/index.md', '/foo/bar/index.html'], + ['/foo/bar/index.html', '/foo/bar/index.html'], + ['/foo', '/foo.html'], + ['/foo.md', '/foo.html'], + ['/foo.html', '/foo.html'], + ['/foo/bar', '/foo/bar.html'], + ['/foo/bar.md', '/foo/bar.html'], + ['/foo/bar.html', '/foo/bar.html'], + ['/foo/bar/baz', '/foo/bar/baz.html'], + ['/foo/bar/baz.md', '/foo/bar/baz.html'], + ['/foo/bar/baz.html', '/foo/bar/baz.html'], + ['https://example.com/', 'https://example.com/index.html'], + ['https://example.com/index', 'https://example.com/index.html'], + ['https://example.com/index.md', 'https://example.com/index.html'], + ['https://example.com/index.html', 'https://example.com/index.html'], + ['https://example.com/foo/', 'https://example.com/foo/index.html'], + ['https://example.com/foo/bar/', 'https://example.com/foo/bar/index.html'], + [ + 'https://example.com/foo/bar/index', + 'https://example.com/foo/bar/index.html', + ], + [ + 'https://example.com/foo/bar/index.md', + 'https://example.com/foo/bar/index.html', + ], + [ + 'https://example.com/foo/bar/index.html', + 'https://example.com/foo/bar/index.html', + ], + ['https://example.com/foo', 'https://example.com/foo.html'], + ['https://example.com/foo.md', 'https://example.com/foo.html'], + ['https://example.com/foo.html', 'https://example.com/foo.html'], + ['https://example.com/foo/bar', 'https://example.com/foo/bar.html'], + ['https://example.com/foo/bar.md', 'https://example.com/foo/bar.html'], + ['https://example.com/foo/bar.html', 'https://example.com/foo/bar.html'], + ['https://example.com/foo/bar/baz', 'https://example.com/foo/bar/baz.html'], + [ + 'https://example.com/foo/bar/baz.md', + 'https://example.com/foo/bar/baz.html', + ], + [ + 'https://example.com/foo/bar/baz.html', + 'https://example.com/foo/bar/baz.html', + ], + ] + + testCases.forEach(([input, output]) => { + expect(normalizePath(input)).toBe(output) + }) +}) diff --git a/plugins/plugin-redirect/tsconfig.build.json b/plugins/plugin-redirect/tsconfig.build.json new file mode 100644 index 0000000000..e0a82d8177 --- /dev/null +++ b/plugins/plugin-redirect/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "types": ["vuepress/client-types"] + }, + "include": ["./src"], + "references": [{ "path": "../../tools/helper/tsconfig.build.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aad32601e..2319ceaad7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@vuepress/plugin-feed': specifier: workspace:* version: link:../plugins/plugin-feed + '@vuepress/plugin-redirect': + specifier: workspace:* + version: link:../plugins/plugin-redirect '@vuepress/theme-default': specifier: workspace:* version: link:../themes/theme-default @@ -420,6 +423,27 @@ importers: specifier: 2.0.0-rc.2 version: 2.0.0-rc.2(@vuepress/bundler-vite@2.0.0-rc.2)(@vuepress/bundler-webpack@2.0.0-rc.2)(typescript@5.3.3)(vue@3.4.15) + plugins/plugin-redirect: + dependencies: + '@vuepress/helper': + specifier: workspace:* + version: link:../../tools/helper + '@vueuse/core': + specifier: ^10.7.2 + version: 10.7.2(vue@3.4.15) + cac: + specifier: ^6.7.14 + version: 6.7.14 + vue: + specifier: ^3.4.15 + version: 3.4.15(typescript@5.3.3) + vue-router: + specifier: ^4.2.5 + version: 4.2.5(vue@3.4.15) + vuepress: + specifier: 2.0.0-rc.2 + version: 2.0.0-rc.2(@vuepress/bundler-vite@2.0.0-rc.2)(@vuepress/bundler-webpack@2.0.0-rc.2)(typescript@5.3.3)(vue@3.4.15) + plugins/plugin-register-components: dependencies: chokidar: diff --git a/tsconfig.build.json b/tsconfig.build.json index b283bf077d..db7577f74c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -31,6 +31,7 @@ }, { "path": "./plugins/plugin-pwa/tsconfig.build.json" }, { "path": "./plugins/plugin-pwa-popup/tsconfig.build.json" }, + { "path": "./plugins/plugin-redirect/tsconfig.build.json" }, { "path": "./plugins/plugin-reading-time/tsconfig.build.json" }, { "path": "./plugins/plugin-register-components/tsconfig.build.json"