mirror of
https://github.com/alibaba/ice.git
synced 2024-10-23 07:04:52 +08:00
feat: i18n plugin (#6149)
* feat: antd5 example with i18n * feat: fusion example with i18n * feat: init plugin-i18n * feat: init i18n example * chore: lock * feat: add language selector * feat: i18n route * feat: getAllLocales and getDefaultLocale API * fix: ssr render error * feat: support auto redirect * fix: redirect without url param * feat: support add response handler * chore: lock * feat: support blockCookie option * fix: route test * fix: ssg error * fix: i18n response * feat: add server example * chore: add basename * chore: add headers * feat: i18n docs * fix: i18n plugin * feat: enhance disable cookie * docs: update disabledCookie documentation * chore: update desc * fix: param not defined * fix: generate routes.ts * fix: remove todo * test: i18n * test: i18n * fix: lint warning * fix: addDefineRoutesFunc to addRoutesDefinition * chore: i18n plugin version * chore: changelog * feat: use modifyRenderData instead of runtimeOptions.raw * chore: update changelog * chore: rename param * fix: comment * feat: use createLogger instead of consola directly * feat: change customRuntimeOptions type from string to object * refactor: route id * chore: remove unused console.log * chore: add comment * fix: win32 test fail * chore: changelog
This commit is contained in:
parent
3ede3c5a2a
commit
1c3d3fec6c
5
.changeset/dirty-bats-fly.md
Normal file
5
.changeset/dirty-bats-fly.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@ice/app': patch
|
||||
---
|
||||
|
||||
feat: support add routes definition
|
5
.changeset/fluffy-hounds-smoke.md
Normal file
5
.changeset/fluffy-hounds-smoke.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@ice/route-manifest': minor
|
||||
---
|
||||
|
||||
refactor: route id generation
|
5
.changeset/hip-balloons-brush.md
Normal file
5
.changeset/hip-balloons-brush.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@ice/plugin-i18n': major
|
||||
---
|
||||
|
||||
feat: init plugin
|
5
.changeset/hot-baboons-retire.md
Normal file
5
.changeset/hot-baboons-retire.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@ice/route-manifest': minor
|
||||
---
|
||||
|
||||
feat: support accept one more `defineExtraRoutes` functions
|
5
.changeset/red-gorillas-remember.md
Normal file
5
.changeset/red-gorillas-remember.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@ice/runtime': patch
|
||||
---
|
||||
|
||||
feat: support handler response
|
5
.changeset/tasty-spies-think.md
Normal file
5
.changeset/tasty-spies-think.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@ice/app': patch
|
||||
---
|
||||
|
||||
fix: routeSpecifier is not unique
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -40,6 +40,7 @@ yalc.lock
|
||||
# Packages
|
||||
packages/*/lib/
|
||||
packages/*/esm/
|
||||
packages/*/es2017/
|
||||
|
||||
# temp folder .ice
|
||||
examples/*/.ice
|
||||
|
5
examples/with-antd5/ice.config.mts
Normal file
5
examples/with-antd5/ice.config.mts
Normal file
@ -0,0 +1,5 @@
|
||||
import { defineConfig } from '@ice/app';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
ssg: false,
|
||||
}));
|
22
examples/with-antd5/package.json
Normal file
22
examples/with-antd5/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@examples/with-antd5",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "ice start",
|
||||
"build": "ice build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ice/app": "workspace:*",
|
||||
"@ice/runtime": "workspace:*",
|
||||
"antd": "^5.0.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-intl": "^6.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.2"
|
||||
}
|
||||
}
|
7
examples/with-antd5/src/app.tsx
Normal file
7
examples/with-antd5/src/app.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineAppConfig } from 'ice';
|
||||
|
||||
export default defineAppConfig(() => ({
|
||||
app: {
|
||||
rootId: 'app',
|
||||
},
|
||||
}));
|
22
examples/with-antd5/src/document.tsx
Normal file
22
examples/with-antd5/src/document.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Meta, Title, Links, Main, Scripts } from 'ice';
|
||||
|
||||
function Document() {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="description" content="ICE Demo" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Title />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Main />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default Document;
|
10
examples/with-antd5/src/locales.ts
Normal file
10
examples/with-antd5/src/locales.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const messages: Record<string, any> = {
|
||||
en: {
|
||||
changeLanguageTitle: 'Change locale:',
|
||||
indexTitle: 'Index',
|
||||
},
|
||||
'zh-cn': {
|
||||
changeLanguageTitle: '修改语言:',
|
||||
indexTitle: '首页',
|
||||
},
|
||||
};
|
12
examples/with-antd5/src/pages/index.tsx
Normal file
12
examples/with-antd5/src/pages/index.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { DatePicker, Pagination } from 'antd';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div>
|
||||
<h2><FormattedMessage id="indexTitle" /></h2>
|
||||
<Pagination defaultCurrent={1} total={50} showSizeChanger />
|
||||
<DatePicker />
|
||||
</div>
|
||||
);
|
||||
}
|
47
examples/with-antd5/src/pages/layout.tsx
Normal file
47
examples/with-antd5/src/pages/layout.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Outlet } from 'ice';
|
||||
import { useState } from 'react';
|
||||
import { IntlProvider, FormattedMessage } from 'react-intl';
|
||||
import { ConfigProvider, Radio } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import type { Locale } from 'antd/es/locale';
|
||||
|
||||
import * as dayjs from 'dayjs';
|
||||
import { messages } from '@/locales';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
export default function Layout() {
|
||||
const [locale, setLocale] = useState<Locale>(enUS);
|
||||
|
||||
const changeLocale = (e: RadioChangeEvent) => {
|
||||
const localeValue = e.target.value;
|
||||
setLocale(localeValue);
|
||||
if (localeValue) {
|
||||
dayjs.locale('zh-cn');
|
||||
} else {
|
||||
dayjs.locale('en');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<IntlProvider locale={locale.locale} messages={messages[locale.locale]}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ marginRight: 16 }}><FormattedMessage id="changeLanguageTitle" /></span>
|
||||
<Radio.Group value={locale} onChange={changeLocale}>
|
||||
<Radio.Button key="en" value={enUS}>
|
||||
English
|
||||
</Radio.Button>
|
||||
<Radio.Button key="cn" value={zhCN}>
|
||||
中文
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<ConfigProvider locale={locale}>
|
||||
<Outlet />
|
||||
</ConfigProvider>
|
||||
</IntlProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
1
examples/with-antd5/src/typings.d.ts
vendored
Normal file
1
examples/with-antd5/src/typings.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="@ice/app/types" />
|
17
examples/with-antd5/tsconfig.json
Normal file
17
examples/with-antd5/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "ESNext", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"ice": [".ice"]
|
||||
}
|
||||
},
|
||||
"include": ["src", ".ice"],
|
||||
}
|
@ -16,4 +16,5 @@ export default defineConfig({
|
||||
locales: ['af'],
|
||||
}),
|
||||
],
|
||||
ssg: false,
|
||||
});
|
||||
|
@ -12,16 +12,17 @@
|
||||
"dependencies": {
|
||||
"@alifd/next": "^1.25.49",
|
||||
"@ice/runtime": "workspace:*",
|
||||
"moment": "^2.29.4",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"moment": "^2.29.4"
|
||||
"react-intl": "^6.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.2",
|
||||
"@ice/app": "workspace:*",
|
||||
"@ice/plugin-css-assets-local": "workspace:*",
|
||||
"@ice/plugin-fusion": "workspace:*",
|
||||
"@ice/plugin-moment-locales": "workspace:*"
|
||||
"@ice/plugin-moment-locales": "workspace:*",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.2"
|
||||
}
|
||||
}
|
||||
|
8
examples/with-fusion/src/locales.ts
Normal file
8
examples/with-fusion/src/locales.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const messages: Record<string, any> = {
|
||||
en: {
|
||||
buttonText: 'Button',
|
||||
},
|
||||
'zh-cn': {
|
||||
buttonText: '按钮',
|
||||
},
|
||||
};
|
@ -1,11 +1,15 @@
|
||||
import { Button } from '@alifd/next';
|
||||
import { Button, DatePicker } from '@alifd/next';
|
||||
import '@alifd/next/dist/next.css';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<h1>with fusion</h1>
|
||||
<Button type="primary">Button</Button>
|
||||
<DatePicker />
|
||||
<Button type="primary">
|
||||
<FormattedMessage id="buttonText" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
46
examples/with-fusion/src/pages/layout.tsx
Normal file
46
examples/with-fusion/src/pages/layout.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Outlet } from 'ice';
|
||||
import { useState } from 'react';
|
||||
import { ConfigProvider, Radio } from '@alifd/next';
|
||||
import enUS from '@alifd/next/lib/locale/en-us';
|
||||
import zhCN from '@alifd/next/lib/locale/zh-cn';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { messages } from '@/locales';
|
||||
|
||||
const localeMap = new Map([
|
||||
['en', enUS],
|
||||
['zh-cn', zhCN],
|
||||
]);
|
||||
|
||||
export default function Layout() {
|
||||
const [locale, setLocale] = useState('en');
|
||||
const list = [
|
||||
{
|
||||
value: 'en',
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
value: 'zh-cn',
|
||||
label: '中文',
|
||||
},
|
||||
];
|
||||
|
||||
function changeLocale(value: string) {
|
||||
setLocale(value);
|
||||
}
|
||||
return (
|
||||
<main>
|
||||
<IntlProvider locale={locale} messages={messages[locale]}>
|
||||
<Radio.Group
|
||||
dataSource={list}
|
||||
shape="button"
|
||||
value={locale}
|
||||
onChange={changeLocale}
|
||||
/>
|
||||
<ConfigProvider locale={localeMap.get(locale)}>
|
||||
<Outlet />
|
||||
</ConfigProvider>
|
||||
</IntlProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
13
examples/with-i18n/ice.config.mts
Normal file
13
examples/with-i18n/ice.config.mts
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from '@ice/app';
|
||||
import i18n from '@ice/plugin-i18n';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18n({
|
||||
locales: ['zh-CN', 'en-US'],
|
||||
defaultLocale: 'zh-CN',
|
||||
autoRedirect: true,
|
||||
}),
|
||||
],
|
||||
ssr: true,
|
||||
});
|
26
examples/with-i18n/package.json
Normal file
26
examples/with-i18n/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@examples/with-i18n",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "ice start",
|
||||
"build": "ice build",
|
||||
"serve": "tsx server.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ice/runtime": "workspace:*",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-intl": "^6.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ice/app": "workspace:*",
|
||||
"@ice/plugin-i18n": "workspace:*",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.2",
|
||||
"express": "^4.17.3",
|
||||
"tslib": "^2.5.0",
|
||||
"tsx": "^3.12.1"
|
||||
}
|
||||
}
|
27
examples/with-i18n/server.mts
Normal file
27
examples/with-i18n/server.mts
Normal file
@ -0,0 +1,27 @@
|
||||
import express from 'express';
|
||||
import { renderToHTML } from './build/server/index.mjs';
|
||||
|
||||
const app = express();
|
||||
const port = 4000;
|
||||
const basename = '/app';
|
||||
|
||||
app.use(express.static('build', {}));
|
||||
|
||||
app.use(async (req, res) => {
|
||||
const { statusCode, statusText, headers, value: body } = await renderToHTML({ req, res }, { basename });
|
||||
res.statusCode = statusCode;
|
||||
res.statusMessage = statusText;
|
||||
Object.entries((headers || {}) as Record<string, string>).forEach(([name, value]) => {
|
||||
res.setHeader(name, value);
|
||||
});
|
||||
if (body && req.method !== 'HEAD') {
|
||||
res.end(body);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`App listening on http://localhost:${port}${basename}`);
|
||||
});
|
12
examples/with-i18n/src/app.tsx
Normal file
12
examples/with-i18n/src/app.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineAppConfig } from 'ice';
|
||||
import { defineI18nConfig } from '@ice/plugin-i18n/types';
|
||||
|
||||
export default defineAppConfig(() => ({
|
||||
router: {
|
||||
basename: '/app',
|
||||
},
|
||||
}));
|
||||
|
||||
export const i18nConfig = defineI18nConfig(() => ({
|
||||
// disabledCookie: true,
|
||||
}));
|
22
examples/with-i18n/src/document.tsx
Normal file
22
examples/with-i18n/src/document.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Meta, Title, Links, Main, Scripts } from 'ice';
|
||||
|
||||
function Document() {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="description" content="ICE Demo" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Title />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Main />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default Document;
|
8
examples/with-i18n/src/locales.ts
Normal file
8
examples/with-i18n/src/locales.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const messages: Record<string, any> = {
|
||||
'en-US': {
|
||||
buttonText: 'Normal Button',
|
||||
},
|
||||
'zh-CN': {
|
||||
buttonText: '普通按钮',
|
||||
},
|
||||
};
|
13
examples/with-i18n/src/pages/blog/a.tsx
Normal file
13
examples/with-i18n/src/pages/blog/a.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Link } from 'ice';
|
||||
|
||||
export default function BlogA() {
|
||||
return (
|
||||
<>
|
||||
<h2>Blog A</h2>
|
||||
<ul>
|
||||
<li><Link to="/">Index</Link></li>
|
||||
<li><Link to="/blog">Blog</Link></li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
13
examples/with-i18n/src/pages/blog/index.tsx
Normal file
13
examples/with-i18n/src/pages/blog/index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Link } from 'ice';
|
||||
|
||||
export default function Blog() {
|
||||
return (
|
||||
<>
|
||||
<h2>Blog</h2>
|
||||
<ul>
|
||||
<li><Link to="/">Index</Link></li>
|
||||
<li><Link to="/blog/a">Blog A</Link></li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
15
examples/with-i18n/src/pages/index.tsx
Normal file
15
examples/with-i18n/src/pages/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Link } from 'ice';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<h1>I18n Example</h1>
|
||||
<Link to="/blog">Blog</Link>
|
||||
<br />
|
||||
<button style={{ marginTop: 20 }} id="button">
|
||||
<FormattedMessage id="buttonText" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
38
examples/with-i18n/src/pages/layout.tsx
Normal file
38
examples/with-i18n/src/pages/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Outlet, useLocale, getAllLocales, getDefaultLocale, Link, useLocation } from 'ice';
|
||||
import { IntlProvider as ReactIntlProvider } from 'react-intl';
|
||||
import { messages } from '@/locales';
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const [activeLocale, setLocale] = useLocale();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p><b>Current locale: </b>{activeLocale}</p>
|
||||
<p><b>Default locale: </b>{getDefaultLocale()}</p>
|
||||
<p><b>Configured locales: </b>{JSON.stringify(getAllLocales())}</p>
|
||||
|
||||
<b>Choose language: </b>
|
||||
<ul>
|
||||
{
|
||||
getAllLocales().map((locale: string) => {
|
||||
return (
|
||||
<li key={locale}>
|
||||
<Link
|
||||
to={location.pathname}
|
||||
onClick={() => setLocale(locale)}
|
||||
// state={{ locale }}
|
||||
>
|
||||
{locale}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
<ReactIntlProvider locale={activeLocale} messages={messages[activeLocale]}>
|
||||
<Outlet />
|
||||
</ReactIntlProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
32
examples/with-i18n/tsconfig.json
Normal file
32
examples/with-i18n/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"buildOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "build",
|
||||
"module": "esnext",
|
||||
"target": "es6",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["es6", "dom"],
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"rootDir": "./",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": false,
|
||||
"importHelpers": true,
|
||||
"strictNullChecks": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"ice": [".ice"]
|
||||
}
|
||||
},
|
||||
"include": ["src", ".ice", "ice.config.*"],
|
||||
"exclude": ["build", "public"]
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export default function A() {
|
||||
return <div>111</div>;
|
||||
}
|
@ -7,7 +7,12 @@ import type { Config } from '@ice/webpack-config/types';
|
||||
import type { AppConfig } from '@ice/runtime/types';
|
||||
import webpack from '@ice/bundles/compiled/webpack/index.js';
|
||||
import fg from 'fast-glob';
|
||||
import type { DeclarationData, PluginData, ExtendsPluginAPI, TargetDeclarationData } from './types';
|
||||
import type {
|
||||
DeclarationData,
|
||||
PluginData,
|
||||
ExtendsPluginAPI,
|
||||
TargetDeclarationData,
|
||||
} from './types/index.js';
|
||||
import { DeclarationType } from './types/index.js';
|
||||
import Generator from './service/runtimeGenerator.js';
|
||||
import { createServerCompiler } from './service/serverCompiler.js';
|
||||
@ -19,7 +24,7 @@ import test from './commands/test.js';
|
||||
import getWatchEvents from './getWatchEvents.js';
|
||||
import { setEnv, updateRuntimeEnv, getCoreEnvKeys } from './utils/runtimeEnv.js';
|
||||
import getRuntimeModules from './utils/getRuntimeModules.js';
|
||||
import { generateRoutesInfo, getRoutesDefination } from './routes.js';
|
||||
import { generateRoutesInfo, getRoutesDefinition } from './routes.js';
|
||||
import * as config from './config.js';
|
||||
import { RUNTIME_TMP_DIR, WEB, RUNTIME_EXPORTS, SERVER_ENTRY } from './constant.js';
|
||||
import createSpinner from './utils/createSpinner.js';
|
||||
@ -167,6 +172,7 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
|
||||
getRouteManifest: () => routeManifest.getNestedRoute(),
|
||||
getFlattenRoutes: () => routeManifest.getFlattenRoute(),
|
||||
getRoutesFile: () => routeManifest.getRoutesFile(),
|
||||
addRoutesDefinition: routeManifest.addRoutesDefinition.bind(routeManifest),
|
||||
excuteServerEntry,
|
||||
context: {
|
||||
webpack,
|
||||
@ -212,8 +218,9 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
|
||||
|
||||
const coreEnvKeys = getCoreEnvKeys();
|
||||
|
||||
const routesInfo = await generateRoutesInfo(rootDir, routesConfig);
|
||||
const routesInfo = await generateRoutesInfo(rootDir, routesConfig, routeManifest.getRoutesDefinitions());
|
||||
routeManifest.setRoutes(routesInfo.routes);
|
||||
|
||||
const hasExportAppData = (await getFileExports({ rootDir, file: 'src/app' })).includes('dataLoader');
|
||||
const csr = !userConfig.ssr && !userConfig.ssg;
|
||||
|
||||
@ -230,7 +237,7 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
|
||||
const iceRuntimePath = '@ice/runtime';
|
||||
// Only when code splitting use the default strategy or set to `router`, the router will be lazy loaded.
|
||||
const lazy = [true, 'chunks', 'page'].includes(userConfig.codeSplitting);
|
||||
const { routeImports, routeDefination } = getRoutesDefination(routesInfo.routes, lazy);
|
||||
const { routeImports, routeDefinition } = getRoutesDefinition(routesInfo.routes, lazy);
|
||||
// add render data
|
||||
generator.setRenderData({
|
||||
...routesInfo,
|
||||
@ -250,21 +257,25 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
|
||||
jsOutput: distType.includes('javascript'),
|
||||
dataLoader: userConfig.dataLoader,
|
||||
routeImports,
|
||||
routeDefination,
|
||||
routeDefinition,
|
||||
});
|
||||
dataCache.set('routes', JSON.stringify(routesInfo));
|
||||
dataCache.set('hasExportAppData', hasExportAppData ? 'true' : '');
|
||||
|
||||
// Render exports files if route component export dataLoader / pageConfig.
|
||||
renderExportsTemplate({
|
||||
...routesInfo,
|
||||
hasExportAppData,
|
||||
}, generator.addRenderFile, {
|
||||
rootDir,
|
||||
runtimeDir: RUNTIME_TMP_DIR,
|
||||
templateDir: path.join(templateDir, 'exports'),
|
||||
dataLoader: Boolean(userConfig.dataLoader),
|
||||
});
|
||||
renderExportsTemplate(
|
||||
{
|
||||
...routesInfo,
|
||||
hasExportAppData,
|
||||
},
|
||||
generator.addRenderFile,
|
||||
{
|
||||
rootDir,
|
||||
runtimeDir: RUNTIME_TMP_DIR,
|
||||
templateDir: path.join(templateDir, 'exports'),
|
||||
dataLoader: Boolean(userConfig.dataLoader),
|
||||
},
|
||||
);
|
||||
|
||||
if (typeof userConfig.dataLoader === 'object' && userConfig.dataLoader.fetcher) {
|
||||
const {
|
||||
|
@ -2,7 +2,7 @@ import * as path from 'path';
|
||||
import type { Context } from 'build-scripts';
|
||||
import type { Config } from '@ice/webpack-config/types';
|
||||
import type { WatchEvent } from './types/plugin.js';
|
||||
import { generateRoutesInfo, getRoutesDefination } from './routes.js';
|
||||
import { generateRoutesInfo, getRoutesDefinition } from './routes.js';
|
||||
import type Generator from './service/runtimeGenerator';
|
||||
import getGlobalStyleGlobPattern from './utils/getGlobalStyleGlobPattern.js';
|
||||
import renderExportsTemplate from './utils/renderExportsTemplate.js';
|
||||
@ -30,7 +30,7 @@ const getWatchEvents = (options: Options): WatchEvent[] => {
|
||||
async (eventName: string) => {
|
||||
if (eventName === 'add' || eventName === 'unlink' || eventName === 'change') {
|
||||
const routesRenderData = await generateRoutesInfo(rootDir, routesConfig);
|
||||
const { routeImports, routeDefination } = getRoutesDefination(routesRenderData.routes, lazyRoutes);
|
||||
const { routeImports, routeDefinition } = getRoutesDefinition(routesRenderData.routes, lazyRoutes);
|
||||
const stringifiedData = JSON.stringify(routesRenderData);
|
||||
if (cache.get('routes') !== stringifiedData) {
|
||||
cache.set('routes', stringifiedData);
|
||||
@ -38,9 +38,9 @@ const getWatchEvents = (options: Options): WatchEvent[] => {
|
||||
if (eventName !== 'change') {
|
||||
// Specify the route files to re-render.
|
||||
generator.renderFile(
|
||||
path.join(templateDir, 'routes.ts.ejs'),
|
||||
path.join(rootDir, targetDir, 'routes.ts'),
|
||||
{ routeImports, routeDefination },
|
||||
path.join(templateDir, 'routes.tsx.ejs'),
|
||||
path.join(rootDir, targetDir, 'routes.tsx'),
|
||||
{ routeImports, routeDefinition },
|
||||
);
|
||||
// Keep generate route manifest for avoid breaking change.
|
||||
generator.renderFile(
|
||||
|
@ -1,15 +1,19 @@
|
||||
import * as path from 'path';
|
||||
import { formatNestedRouteManifest, generateRouteManifest } from '@ice/route-manifest';
|
||||
import type { NestedRouteManifest } from '@ice/route-manifest';
|
||||
import type { NestedRouteManifest, DefineExtraRoutes } from '@ice/route-manifest';
|
||||
import type { UserConfig } from './types/userConfig.js';
|
||||
import { getFileExports } from './service/analyze.js';
|
||||
import formatPath from './utils/formatPath.js';
|
||||
|
||||
export async function generateRoutesInfo(rootDir: string, routesConfig: UserConfig['routes'] = {}) {
|
||||
export async function generateRoutesInfo(
|
||||
rootDir: string,
|
||||
routesConfig: UserConfig['routes'] = {},
|
||||
routesDefinitions: DefineExtraRoutes[] = [],
|
||||
) {
|
||||
const routeManifest = generateRouteManifest(
|
||||
rootDir,
|
||||
routesConfig.ignoreFiles,
|
||||
routesConfig.defineRoutes,
|
||||
[routesConfig.defineRoutes, ...routesDefinitions],
|
||||
routesConfig.config,
|
||||
);
|
||||
|
||||
@ -51,9 +55,9 @@ export default {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoutesDefination(nestRouteManifest: NestedRouteManifest[], lazy = false, depth = 0) {
|
||||
export function getRoutesDefinition(nestRouteManifest: NestedRouteManifest[], lazy = false, depth = 0) {
|
||||
const routeImports: string[] = [];
|
||||
const routeDefination = nestRouteManifest.reduce((prev, route, currentIndex) => {
|
||||
const routeDefinition = nestRouteManifest.reduce((prev, route) => {
|
||||
const { children, path: routePath, index, componentName, file, id, layout, exports } = route;
|
||||
|
||||
const componentPath = id.startsWith('__') ? file : `@/pages/${file}`.replace(new RegExp(`${path.extname(file)}$`), '');
|
||||
@ -62,7 +66,7 @@ export function getRoutesDefination(nestRouteManifest: NestedRouteManifest[], la
|
||||
if (lazy) {
|
||||
loadStatement = `import(/* webpackChunkName: "p_${componentName}" */ '${formatPath(componentPath)}')`;
|
||||
} else {
|
||||
const routeSpecifier = `route_${depth}_${currentIndex}`;
|
||||
const routeSpecifier = id.replace(/[./-]/g, '_').replace(/[:*]/, '$');
|
||||
routeImports.push(`import * as ${routeSpecifier} from '${formatPath(componentPath)}';`);
|
||||
loadStatement = routeSpecifier;
|
||||
}
|
||||
@ -100,9 +104,9 @@ export function getRoutesDefination(nestRouteManifest: NestedRouteManifest[], la
|
||||
routeProperties.push('layout: true,');
|
||||
}
|
||||
if (children) {
|
||||
const res = getRoutesDefination(children, lazy, depth + 1);
|
||||
const res = getRoutesDefinition(children, lazy, depth + 1);
|
||||
routeImports.push(...res.routeImports);
|
||||
routeProperties.push(`children: [${res.routeDefination}]`);
|
||||
routeProperties.push(`children: [${res.routeDefinition}]`);
|
||||
}
|
||||
prev += formatRoutesStr(depth, routeProperties);
|
||||
return prev;
|
||||
@ -110,7 +114,7 @@ export function getRoutesDefination(nestRouteManifest: NestedRouteManifest[], la
|
||||
|
||||
return {
|
||||
routeImports,
|
||||
routeDefination,
|
||||
routeDefinition,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -342,7 +342,7 @@ export default class Generator {
|
||||
const renderExt = '.ejs';
|
||||
const realTargetPath = path.isAbsolute(targetPath)
|
||||
? targetPath : path.join(this.rootDir, this.targetDir, targetPath);
|
||||
// example: templatePath = 'routes.ts.ejs'
|
||||
// example: templatePath = 'routes.tsx.ejs'
|
||||
const realTemplatePath = path.isAbsolute(templatePath)
|
||||
? templatePath : path.join(this.templateDir, templatePath);
|
||||
const { ext } = path.parse(templatePath);
|
||||
|
@ -19,7 +19,7 @@ export interface TargetDeclarationData {
|
||||
declarationType?: DeclarationType;
|
||||
}
|
||||
|
||||
export type RenderData = Record<string, unknown>;
|
||||
export type RenderData = Record<string, any>;
|
||||
type RenderDataFunction = (renderDataFunction: RenderData) => RenderData;
|
||||
export interface TemplateOptions {
|
||||
template: string;
|
||||
|
@ -2,7 +2,7 @@ import type webpack from '@ice/bundles/compiled/webpack';
|
||||
import type { _Plugin, CommandArgs, TaskConfig } from 'build-scripts';
|
||||
import type { Configuration, Stats, WebpackOptionsNormalized } from '@ice/bundles/compiled/webpack';
|
||||
import type { esbuild } from '@ice/bundles';
|
||||
import type { NestedRouteManifest } from '@ice/route-manifest';
|
||||
import type { DefineExtraRoutes, NestedRouteManifest } from '@ice/route-manifest';
|
||||
import type { Config } from '@ice/webpack-config/types';
|
||||
import type { AppConfig, AssetsManifest } from '@ice/runtime/types';
|
||||
import type ServerCompileTask from '../utils/ServerCompileTask.js';
|
||||
@ -161,6 +161,7 @@ export interface ExtendsPluginAPI {
|
||||
getRouteManifest: () => Routes;
|
||||
getFlattenRoutes: () => string[];
|
||||
getRoutesFile: () => string[];
|
||||
addRoutesDefinition: (defineRoutes: DefineExtraRoutes) => void;
|
||||
dataCache: Map<string, string>;
|
||||
createLogger: CreateLogger;
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import type { NestedRouteManifest } from '@ice/route-manifest';
|
||||
import type { NestedRouteManifest, DefineExtraRoutes } from '@ice/route-manifest';
|
||||
import getRoutePath, { getRoutesFile } from './getRoutePaths.js';
|
||||
|
||||
export default class RouteManifest {
|
||||
private routeManifest: NestedRouteManifest[];
|
||||
private routesDefinitions: DefineExtraRoutes[];
|
||||
|
||||
constructor() {
|
||||
this.routeManifest = null;
|
||||
this.routesDefinitions = [];
|
||||
}
|
||||
|
||||
getNestedRoute() {
|
||||
@ -26,4 +28,12 @@ export default class RouteManifest {
|
||||
getRoutesFile() {
|
||||
return getRoutesFile(this.getNestedRoute());
|
||||
}
|
||||
}
|
||||
|
||||
public addRoutesDefinition(defineRoutes: DefineExtraRoutes) {
|
||||
this.routesDefinitions.push(defineRoutes);
|
||||
}
|
||||
|
||||
public getRoutesDefinitions() {
|
||||
return this.routesDefinitions;
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ const getRouterBasename = () => {
|
||||
// Otherwise chunk of route component will pack @ice/jsx-runtime and depend on framework bundle.
|
||||
const App = <></>;
|
||||
|
||||
const render = (customOptions = {}) => {
|
||||
const render = (customOptions: Record<string, any> = {}) => {
|
||||
const appProps = {
|
||||
app,
|
||||
runtimeModules: {
|
||||
@ -29,12 +29,15 @@ const render = (customOptions = {}) => {
|
||||
memoryRouter: <%- memoryRouter || false %>,
|
||||
<% if(dataLoaderImport.imports) {-%>dataLoaderFetcher,<% } -%>
|
||||
...customOptions,
|
||||
<% if (runtimeOptions.exports) { -%>
|
||||
runtimeOptions: {
|
||||
<%- runtimeOptions.exports %>
|
||||
...(customOptions.runtimeOptions || {}),
|
||||
<% if (runtimeOptions.exports) { -%>
|
||||
<%- runtimeOptions.exports %>
|
||||
<% } -%>
|
||||
<% if (locals.customRuntimeOptions) { -%>
|
||||
...<%- JSON.stringify(customRuntimeOptions) %>,
|
||||
<% } -%>
|
||||
...customOptions.runtimeOptions,
|
||||
},
|
||||
<% } -%>
|
||||
};
|
||||
return runClientApp(appProps);
|
||||
};
|
||||
|
@ -92,10 +92,13 @@ function mergeOptions(options) {
|
||||
routesConfig,
|
||||
distType,
|
||||
serverData,
|
||||
<% if (runtimeOptions.exports) { -%>
|
||||
runtimeOptions: {
|
||||
<% if (runtimeOptions.exports) { -%>
|
||||
<%- runtimeOptions.exports %>
|
||||
<% } -%>
|
||||
<% if (locals.customRuntimeOptions) { _%>
|
||||
...<%- JSON.stringify(customRuntimeOptions) %>,
|
||||
<% } _%>
|
||||
},
|
||||
<% } -%>
|
||||
};
|
||||
}
|
||||
|
@ -4,5 +4,5 @@ export default ({
|
||||
requestContext,
|
||||
renderMode,
|
||||
}) => ([
|
||||
<%- routeDefination %>
|
||||
<%- routeDefinition %>
|
||||
]);
|
||||
|
46
packages/plugin-i18n/README.md
Normal file
46
packages/plugin-i18n/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# @ice/plugin-i18n
|
||||
|
||||
组件功能描述
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
$ npm i @ice/plugin-i18n --save-dev
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { defineConfig } from '@ice/app';
|
||||
import i18n from '@ice/plugin-i18n';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18n({
|
||||
locales: ['zh-CN', 'en-US'],
|
||||
defaultLocale: 'zh-CN',
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### `locales`
|
||||
|
||||
- **type:** `string[]`
|
||||
|
||||
The locales you want to support in your app. This option is required.
|
||||
|
||||
### defaultLocale
|
||||
|
||||
- **type:** `string`
|
||||
|
||||
The default locale you want to be used when visiting a non-locale prefixed path. This option is required.
|
||||
|
||||
### autoRedirect
|
||||
|
||||
- **type:** `boolean`
|
||||
- **default:** `true`
|
||||
|
||||
Redirect to the preferred locale automatically. This option should be used with the middleware. If you deploy your application in production, you should read the [example]() for more detail.
|
8
packages/plugin-i18n/build.config.mts
Normal file
8
packages/plugin-i18n/build.config.mts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from '@ice/pkg';
|
||||
|
||||
// https://pkg.ice.work/reference/config-list/
|
||||
export default defineConfig({
|
||||
transform: {
|
||||
formats: ['es2017'],
|
||||
},
|
||||
});
|
65
packages/plugin-i18n/package.json
Normal file
65
packages/plugin-i18n/package.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@ice/plugin-i18n",
|
||||
"version": "0.0.0",
|
||||
"description": "I18n plugin for ice.js 3.",
|
||||
"files": [
|
||||
"es2017",
|
||||
"!es2017/**/*.map"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "es2017/index.js",
|
||||
"module": "es2017/index.js",
|
||||
"types": "es2017/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./es2017/index.d.ts",
|
||||
"import": "./es2017/index.js",
|
||||
"default": "./es2017/index.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./es2017/runtime/index.d.ts",
|
||||
"import": "./es2017/runtime/index.js",
|
||||
"default": "./es2017/runtime/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./es2017/types.d.ts",
|
||||
"import": "./es2017/types.js",
|
||||
"default": "./es2017/types.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"watch": "ice-pkg start",
|
||||
"build": "ice-pkg build"
|
||||
},
|
||||
"keywords": [
|
||||
"ice.js",
|
||||
"i18n",
|
||||
"plugin"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ice/jsx-runtime": "^0.2.0",
|
||||
"@swc/helpers": "^0.4.14",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"url-join": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ice/pkg": "^1.0.0",
|
||||
"@ice/app": "workspace:^",
|
||||
"@ice/runtime": "workspace:^",
|
||||
"@remix-run/router": "^1.5.0",
|
||||
"@types/react": "^18.0.33",
|
||||
"@types/accept-language-parser": "^1.5.3",
|
||||
"webpack-dev-server": "^4.13.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ice/app": "^3.0.0",
|
||||
"@ice/runtime": "^1.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
1
packages/plugin-i18n/runtime.d.ts
vendored
Normal file
1
packages/plugin-i18n/runtime.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export * from './es2017/runtime/index';
|
1
packages/plugin-i18n/src/constants.ts
Normal file
1
packages/plugin-i18n/src/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const LOCALE_COOKIE_NAME = 'ice_locale';
|
85
packages/plugin-i18n/src/index.ts
Normal file
85
packages/plugin-i18n/src/index.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import * as path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { Plugin } from '@ice/app/types';
|
||||
import type { CreateLoggerReturnType } from '@ice/app';
|
||||
import type { I18nConfig } from './types.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const _dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJSON = _require('../package.json');
|
||||
const { name: packageName } = packageJSON;
|
||||
|
||||
const plugin: Plugin<I18nConfig> = (i18nConfig) => ({
|
||||
name: packageName,
|
||||
setup: ({ addRoutesDefinition, generator, createLogger }) => {
|
||||
const logger = createLogger('plugin-i18n');
|
||||
checkPluginOptions(i18nConfig, logger);
|
||||
|
||||
const { locales, defaultLocale } = i18nConfig;
|
||||
const prefixedLocales = locales.filter(locale => locale !== defaultLocale);
|
||||
|
||||
const defineRoutes: Parameters<typeof addRoutesDefinition>[0] = (defineRoute, options) => {
|
||||
function defineChildrenRoutes(children: any[], prefixedLocale: string) {
|
||||
children.forEach(child => {
|
||||
defineRoute(
|
||||
child.path,
|
||||
child.file,
|
||||
{ index: child.index },
|
||||
() => {
|
||||
if (child.children) {
|
||||
defineChildrenRoutes(child.children, prefixedLocale);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
prefixedLocales.forEach(prefixedLocale => {
|
||||
options.nestedRouteManifest.forEach(route => {
|
||||
const newRoutePath = `${prefixedLocale}${route.path ? `/${route.path}` : ''}`;
|
||||
defineRoute(newRoutePath, route.file, { index: route.index }, () => {
|
||||
route.children && defineChildrenRoutes(route.children, prefixedLocale);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
addRoutesDefinition(defineRoutes);
|
||||
|
||||
generator.addRenderFile(
|
||||
path.join(_dirname, 'templates/plugin-i18n.ts.ejs'),
|
||||
'plugin-i18n.ts',
|
||||
{
|
||||
defaultLocale: defaultLocale,
|
||||
locales: JSON.stringify(locales),
|
||||
},
|
||||
);
|
||||
generator.addExport({
|
||||
specifier: ['getDefaultLocale', 'getAllLocales'],
|
||||
source: './plugin-i18n',
|
||||
});
|
||||
generator.addExport({
|
||||
specifier: ['withLocale', 'useLocale'],
|
||||
source: `${packageName}/runtime`,
|
||||
});
|
||||
|
||||
generator.modifyRenderData((renderData) => {
|
||||
renderData.customRuntimeOptions ||= {};
|
||||
(renderData.customRuntimeOptions as Record<string, any>).i18nConfig = i18nConfig;
|
||||
return renderData;
|
||||
});
|
||||
},
|
||||
runtime: `${packageName}/runtime`,
|
||||
});
|
||||
|
||||
function checkPluginOptions(options: I18nConfig, logger: CreateLoggerReturnType) {
|
||||
const { locales, defaultLocale } = options;
|
||||
if (!Array.isArray(locales)) {
|
||||
logger.error(`The plugin option \`locales\` type should be array but received ${typeof locales}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof defaultLocale !== 'string') {
|
||||
logger.error(`The plugin option \`defaultLocale\` type should be string but received ${typeof defaultLocale}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin;
|
58
packages/plugin-i18n/src/runtime/I18nContext.tsx
Normal file
58
packages/plugin-i18n/src/runtime/I18nContext.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import type { ReactElement, SetStateAction, Dispatch } from 'react';
|
||||
import React, { createContext, useState, useContext } from 'react';
|
||||
import normalizeLocalePath from '../utils/normalizeLocalePath.js';
|
||||
import setLocaleToCookie from '../utils/setLocaleToCookie.js';
|
||||
import type { I18nConfig } from '../types.js';
|
||||
|
||||
type ContextValue = [string, Dispatch<SetStateAction<string>>];
|
||||
|
||||
interface I18nProvider {
|
||||
children: ReactElement;
|
||||
locales: I18nConfig['locales'];
|
||||
defaultLocale: I18nConfig['defaultLocale'];
|
||||
pathname: string;
|
||||
disableCookie: boolean;
|
||||
basename?: string;
|
||||
headers?: {
|
||||
[key: string]: string | string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const I18nContext = createContext<ContextValue>(null);
|
||||
|
||||
I18nContext.displayName = 'I18nContext';
|
||||
|
||||
export function I18nProvider({
|
||||
children,
|
||||
locales,
|
||||
defaultLocale,
|
||||
disableCookie,
|
||||
pathname,
|
||||
basename,
|
||||
}: I18nProvider) {
|
||||
const [locale, updateLocale] = useState<string>(
|
||||
normalizeLocalePath({ pathname, basename, locales: locales }).pathLocale || defaultLocale,
|
||||
);
|
||||
|
||||
function setLocale(locale: string) {
|
||||
!disableCookie && setLocaleToCookie(locale);
|
||||
updateLocale(locale);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={[locale, setLocale]}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
export function withLocale<Props>(Component: React.ComponentType<Props>) {
|
||||
return (props: Props) => {
|
||||
const [locale, setLocale] = useLocale();
|
||||
return <Component {...props} locale={locale} setLocale={setLocale} />;
|
||||
};
|
||||
}
|
79
packages/plugin-i18n/src/runtime/hijackHistory.tsx
Normal file
79
packages/plugin-i18n/src/runtime/hijackHistory.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import type { History, To } from '@remix-run/router';
|
||||
import urlJoin from 'url-join';
|
||||
import detectLocale from '../utils/detectLocale.js';
|
||||
import normalizeLocalePath from '../utils/normalizeLocalePath.js';
|
||||
import type { I18nConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Hijack history in order to add locale prefix to the new route path.
|
||||
*/
|
||||
export default function hijackHistory(
|
||||
history: History,
|
||||
i18nConfig: I18nConfig,
|
||||
disableCookie: boolean,
|
||||
basename?: string,
|
||||
) {
|
||||
const originHistory = { ...history };
|
||||
const { defaultLocale, locales } = i18nConfig;
|
||||
|
||||
function createNewNavigate(type: 'push' | 'replace') {
|
||||
return function (toPath: To, state?: Record<string, any>) {
|
||||
const locale = state?.locale;
|
||||
const localePrefixPathname = getLocalePrefixPathname({
|
||||
toPath,
|
||||
basename,
|
||||
locales,
|
||||
currentLocale: locale,
|
||||
defaultLocale,
|
||||
disableCookie,
|
||||
});
|
||||
originHistory[type](localePrefixPathname, state);
|
||||
};
|
||||
}
|
||||
|
||||
history.push = createNewNavigate('push');
|
||||
|
||||
history.replace = createNewNavigate('replace');
|
||||
}
|
||||
|
||||
function getLocalePrefixPathname({
|
||||
toPath,
|
||||
locales,
|
||||
defaultLocale,
|
||||
basename,
|
||||
disableCookie,
|
||||
currentLocale = '',
|
||||
}: {
|
||||
toPath: To;
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
disableCookie: boolean;
|
||||
currentLocale: string;
|
||||
basename?: string;
|
||||
}) {
|
||||
const pathname = getPathname(toPath);
|
||||
const locale = disableCookie ? currentLocale : detectLocale({
|
||||
locales,
|
||||
defaultLocale,
|
||||
pathname,
|
||||
disableCookie,
|
||||
});
|
||||
const { pathname: newPathname } = normalizeLocalePath({ pathname, locales, basename });
|
||||
return urlJoin(
|
||||
basename,
|
||||
locale === defaultLocale ? '' : locale,
|
||||
newPathname,
|
||||
typeof toPath === 'string' ? '' : toPath.search,
|
||||
);
|
||||
}
|
||||
|
||||
function getPathname(toPath: To): string {
|
||||
if (isPathnameString(toPath)) {
|
||||
return toPath;
|
||||
}
|
||||
return toPath.pathname;
|
||||
}
|
||||
|
||||
function isPathnameString(pathname: To): pathname is string {
|
||||
return typeof pathname === 'string';
|
||||
}
|
89
packages/plugin-i18n/src/runtime/index.tsx
Normal file
89
packages/plugin-i18n/src/runtime/index.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import type { RuntimePlugin } from '@ice/runtime/types';
|
||||
import detectLocale from '../utils/detectLocale.js';
|
||||
import type { I18nAppConfig, I18nConfig } from '../types.js';
|
||||
import getLocaleRedirectPath from '../utils/getLocaleRedirectPath.js';
|
||||
import { I18nProvider, useLocale, withLocale } from './I18nContext.js';
|
||||
import hijackHistory from './hijackHistory.js';
|
||||
|
||||
const EXPORT_NAME = 'i18nConfig';
|
||||
// Mock it to avoid ssg error and use new URL to parse url instead of url.parse.
|
||||
const baseUrl = 'http://127.0.0.1';
|
||||
|
||||
const runtime: RuntimePlugin<{ i18nConfig: I18nConfig }> = async (
|
||||
{
|
||||
appContext,
|
||||
addProvider,
|
||||
history,
|
||||
addResponseHandler,
|
||||
},
|
||||
runtimeOptions,
|
||||
) => {
|
||||
const { basename, requestContext, appExport } = appContext;
|
||||
const exported = appExport[EXPORT_NAME];
|
||||
const i18nAppConfig: I18nAppConfig = Object.assign(
|
||||
{ disableCookie: false },
|
||||
(typeof exported === 'function' ? await exported() : exported),
|
||||
);
|
||||
const disableCookie = typeof i18nAppConfig.disableCookie === 'function'
|
||||
? i18nAppConfig.disableCookie()
|
||||
: i18nAppConfig.disableCookie;
|
||||
|
||||
const { i18nConfig } = runtimeOptions;
|
||||
const { locales, defaultLocale, autoRedirect } = i18nConfig;
|
||||
|
||||
addProvider(({ children }) => {
|
||||
return (
|
||||
<I18nProvider
|
||||
pathname={requestContext.pathname}
|
||||
locales={locales}
|
||||
defaultLocale={defaultLocale}
|
||||
basename={basename}
|
||||
disableCookie={disableCookie}
|
||||
headers={requestContext.req?.headers}
|
||||
>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
if (history) {
|
||||
hijackHistory(history, i18nConfig, disableCookie, basename);
|
||||
}
|
||||
|
||||
if (autoRedirect) {
|
||||
addResponseHandler((req) => {
|
||||
const url = new URL(`${baseUrl}${req.url}`);
|
||||
const detectedLocale = detectLocale({
|
||||
locales,
|
||||
defaultLocale,
|
||||
basename,
|
||||
pathname: url.pathname,
|
||||
headers: req.headers,
|
||||
disableCookie: false,
|
||||
});
|
||||
|
||||
const localeRedirectPath = getLocaleRedirectPath({
|
||||
pathname: url.pathname,
|
||||
defaultLocale,
|
||||
detectedLocale,
|
||||
basename,
|
||||
});
|
||||
if (localeRedirectPath) {
|
||||
url.pathname = localeRedirectPath;
|
||||
|
||||
return {
|
||||
statusCode: 302,
|
||||
statusText: 'Found',
|
||||
headers: {
|
||||
location: String(Object.assign(new URL(baseUrl), url)).replace(RegExp(`^${baseUrl}`), ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default runtime;
|
||||
|
||||
export { useLocale, withLocale };
|
7
packages/plugin-i18n/src/templates/plugin-i18n.ts.ejs
Normal file
7
packages/plugin-i18n/src/templates/plugin-i18n.ts.ejs
Normal file
@ -0,0 +1,7 @@
|
||||
export function getDefaultLocale() {
|
||||
return '<%= defaultLocale %>';
|
||||
}
|
||||
|
||||
export function getAllLocales() {
|
||||
return <%- locales %>;
|
||||
}
|
32
packages/plugin-i18n/src/types.ts
Normal file
32
packages/plugin-i18n/src/types.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export interface I18nConfig {
|
||||
/**
|
||||
* The locales which you want to support in your app.
|
||||
*/
|
||||
locales: string[];
|
||||
/**
|
||||
* The default locale which is used when visiting a non-locale prefixed path. e.g `/home`.
|
||||
*/
|
||||
defaultLocale: string;
|
||||
/**
|
||||
* Automatically redirect to the correct path which is based on user's preferred locale.
|
||||
*/
|
||||
autoRedirect?: boolean;
|
||||
}
|
||||
|
||||
export interface I18nAppConfig {
|
||||
/**
|
||||
* Weather or not current application cookie is blocked(authorized).
|
||||
* If it is, we will not get the locale value(ice_locale) from cookie.
|
||||
* @default {false}
|
||||
*/
|
||||
disableCookie?: boolean | (() => boolean);
|
||||
}
|
||||
|
||||
export function defineI18nConfig(
|
||||
configOrDefineConfig: I18nAppConfig | (() => I18nAppConfig),
|
||||
): I18nAppConfig {
|
||||
if (typeof configOrDefineConfig === 'function') {
|
||||
return configOrDefineConfig();
|
||||
}
|
||||
return configOrDefineConfig;
|
||||
}
|
1
packages/plugin-i18n/src/typings.d.ts
vendored
Normal file
1
packages/plugin-i18n/src/typings.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="@ice/pkg/types" />
|
33
packages/plugin-i18n/src/utils/detectLocale.ts
Normal file
33
packages/plugin-i18n/src/utils/detectLocale.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { I18nConfig } from '../types.js';
|
||||
import getLocaleFromCookie from './getLocaleFromCookie.js';
|
||||
import normalizeLocalePath from './normalizeLocalePath.js';
|
||||
import getPreferredLocale from './getPreferredLocale.js';
|
||||
|
||||
interface DetectLocaleParams {
|
||||
locales: I18nConfig['locales'];
|
||||
defaultLocale: I18nConfig['defaultLocale'];
|
||||
pathname: string;
|
||||
disableCookie: boolean;
|
||||
basename?: string;
|
||||
headers?: {
|
||||
[key: string]: string | string[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function detectLocale({
|
||||
locales,
|
||||
defaultLocale,
|
||||
pathname,
|
||||
basename,
|
||||
disableCookie,
|
||||
headers = {},
|
||||
}: DetectLocaleParams): string {
|
||||
const detectedLocale = (
|
||||
normalizeLocalePath({ pathname, locales, basename }).pathLocale ||
|
||||
(!disableCookie && getLocaleFromCookie(locales, headers)) ||
|
||||
getPreferredLocale(locales, headers) ||
|
||||
defaultLocale
|
||||
);
|
||||
|
||||
return detectedLocale;
|
||||
}
|
14
packages/plugin-i18n/src/utils/getLocaleFromCookie.ts
Normal file
14
packages/plugin-i18n/src/utils/getLocaleFromCookie.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { I18nConfig } from 'src/types.js';
|
||||
import Cookies from 'universal-cookie';
|
||||
import { LOCALE_COOKIE_NAME } from '../constants.js';
|
||||
|
||||
export default function getLocaleFromCookie(
|
||||
locales: I18nConfig['locales'],
|
||||
headers: { [key: string]: string | string[] | undefined } = {},
|
||||
) {
|
||||
const cookies: Cookies = new Cookies(typeof window === 'undefined' ? headers.cookie : undefined);
|
||||
const iceLocale = cookies.get(LOCALE_COOKIE_NAME);
|
||||
const locale = locales.find(locale => iceLocale === locale) || undefined;
|
||||
|
||||
return locale;
|
||||
}
|
24
packages/plugin-i18n/src/utils/getLocaleRedirectPath.ts
Normal file
24
packages/plugin-i18n/src/utils/getLocaleRedirectPath.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import urlJoin from 'url-join';
|
||||
import type { I18nConfig } from '../types.js';
|
||||
import removeBasenameFromPath from './removeBasenameFromPath.js';
|
||||
|
||||
interface GetRedirectPathOptions {
|
||||
pathname: string;
|
||||
defaultLocale: I18nConfig['defaultLocale'];
|
||||
detectedLocale: string;
|
||||
basename?: string;
|
||||
}
|
||||
|
||||
export default function getLocaleRedirectPath({
|
||||
pathname,
|
||||
defaultLocale,
|
||||
detectedLocale,
|
||||
basename,
|
||||
}: GetRedirectPathOptions) {
|
||||
const normalizedPathname = removeBasenameFromPath(pathname, basename);
|
||||
const isRootPath = normalizedPathname === '/';
|
||||
|
||||
if (isRootPath && defaultLocale !== detectedLocale) {
|
||||
return urlJoin(basename, detectedLocale);
|
||||
}
|
||||
}
|
14
packages/plugin-i18n/src/utils/getPreferredLocale.ts
Normal file
14
packages/plugin-i18n/src/utils/getPreferredLocale.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { pick as acceptLanguagePick } from 'accept-language-parser';
|
||||
|
||||
/**
|
||||
* Get the preferred locale by Accept-Language in request headers(Server) or window.navigator.languages(Client)
|
||||
*/
|
||||
export default function getPreferredLocale(locales: string[], headers: { [key: string]: string | string[] } = {}) {
|
||||
if (typeof window === 'undefined') {
|
||||
const acceptLanguageValue = headers?.['accept-language'] as string;
|
||||
return acceptLanguagePick(locales, acceptLanguageValue);
|
||||
} else {
|
||||
const acceptLanguages = window.navigator.languages || [];
|
||||
return acceptLanguages.find(acceptLanguage => locales.includes(acceptLanguage));
|
||||
}
|
||||
}
|
29
packages/plugin-i18n/src/utils/normalizeLocalePath.ts
Normal file
29
packages/plugin-i18n/src/utils/normalizeLocalePath.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { I18nConfig } from '../types.js';
|
||||
import removeBasenameFromPath from './removeBasenameFromPath.js';
|
||||
|
||||
interface NormalizeLocalePathOptions {
|
||||
pathname: string;
|
||||
locales: I18nConfig['locales'];
|
||||
basename?: string;
|
||||
}
|
||||
|
||||
export default function normalizeLocalePath({ pathname, locales, basename }: NormalizeLocalePathOptions) {
|
||||
const originPathname = removeBasenameFromPath(pathname, basename);
|
||||
const subPaths = originPathname.split('/');
|
||||
let newPathname = originPathname;
|
||||
let pathLocale: string | undefined;
|
||||
|
||||
for (const locale of locales) {
|
||||
if (subPaths[1] && subPaths[1] === locale) {
|
||||
pathLocale = locale;
|
||||
subPaths.splice(1, 1);
|
||||
newPathname = subPaths.join('/') || '/';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pathname: newPathname,
|
||||
pathLocale,
|
||||
};
|
||||
}
|
18
packages/plugin-i18n/src/utils/removeBasenameFromPath.ts
Normal file
18
packages/plugin-i18n/src/utils/removeBasenameFromPath.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export default function removeBasenameFromPath(pathname: string, basename?: string) {
|
||||
if (typeof basename !== 'string') {
|
||||
return pathname;
|
||||
}
|
||||
if (basename[0] !== '/') {
|
||||
// compatible with no slash. For example: ice -> /ice
|
||||
basename = `/${basename}`;
|
||||
}
|
||||
|
||||
if (pathname.startsWith(basename)) {
|
||||
pathname = pathname.substring(basename.length);
|
||||
if (!pathname.startsWith('/')) {
|
||||
pathname = `/${pathname || ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
10
packages/plugin-i18n/src/utils/setLocaleToCookie.ts
Normal file
10
packages/plugin-i18n/src/utils/setLocaleToCookie.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import Cookies from 'universal-cookie';
|
||||
import { LOCALE_COOKIE_NAME } from '../constants.js';
|
||||
|
||||
/**
|
||||
* NOTE: Call this function in browser.
|
||||
*/
|
||||
export default function setLocaleToCookie(locale: string) {
|
||||
const cookies = new Cookies();
|
||||
cookies.set(LOCALE_COOKIE_NAME, locale, { path: '/' });
|
||||
}
|
8
packages/plugin-i18n/tsconfig.json
Normal file
8
packages/plugin-i18n/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"rootDir": "src",
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
1
packages/plugin-i18n/types.d.ts
vendored
Normal file
1
packages/plugin-i18n/types.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export * from './es2017/types';
|
@ -34,7 +34,7 @@ const plugin: Plugin<PluginOptions> = (options) => ({
|
||||
const { template = true, preload = false } = options || {};
|
||||
const { command, rootDir } = context;
|
||||
|
||||
const logger = createLogger('PHA');
|
||||
const logger = createLogger('plugin-pha');
|
||||
|
||||
// Get variable blows from task config.
|
||||
let compiler: Compiler;
|
||||
|
@ -44,8 +44,8 @@
|
||||
"micromatch": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ice/app": "^3.1.2",
|
||||
"@ice/runtime": "^1.1.3",
|
||||
"@ice/app": "workspace:^",
|
||||
"@ice/runtime": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@types/react": "^18.0.0",
|
||||
|
@ -2,14 +2,30 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import minimatch from 'minimatch';
|
||||
import { createComponentName, createRouteId, defineRoutes, normalizeSlashes } from './routes.js';
|
||||
import type { RouteManifest, DefineRouteFunction, NestedRouteManifest, ConfigRoute } from './routes.js';
|
||||
import {
|
||||
createComponentName,
|
||||
removeLastLayoutStrFromId,
|
||||
createRouteId,
|
||||
defineRoutes,
|
||||
normalizeSlashes,
|
||||
createFileId,
|
||||
} from './routes.js';
|
||||
import type {
|
||||
RouteManifest,
|
||||
DefineRouteFunction,
|
||||
NestedRouteManifest,
|
||||
ConfigRoute,
|
||||
DefineRoutesOptions,
|
||||
DefineExtraRoutes,
|
||||
} from './routes.js';
|
||||
|
||||
export type {
|
||||
RouteManifest,
|
||||
NestedRouteManifest,
|
||||
DefineRouteFunction,
|
||||
ConfigRoute,
|
||||
DefineExtraRoutes,
|
||||
DefineRoutesOptions,
|
||||
};
|
||||
|
||||
export interface RouteItem {
|
||||
@ -38,7 +54,7 @@ export function isRouteModuleFile(filename: string): boolean {
|
||||
export function generateRouteManifest(
|
||||
rootDir: string,
|
||||
ignoreFiles: string[] = [],
|
||||
defineExtraRoutes?: (defineRoute: DefineRouteFunction) => void,
|
||||
defineExtraRoutesQueue?: DefineExtraRoutes[],
|
||||
routeConfig?: RouteItem[],
|
||||
) {
|
||||
const srcDir = path.join(rootDir, 'src');
|
||||
@ -47,6 +63,10 @@ export function generateRouteManifest(
|
||||
if (fs.existsSync(path.resolve(srcDir, 'pages'))) {
|
||||
const conventionalRoutes = defineConventionalRoutes(
|
||||
rootDir,
|
||||
{
|
||||
routeManifest,
|
||||
nestedRouteManifest: formatNestedRouteManifest(routeManifest),
|
||||
},
|
||||
ignoreFiles,
|
||||
);
|
||||
|
||||
@ -59,15 +79,25 @@ export function generateRouteManifest(
|
||||
}
|
||||
}
|
||||
// 3. add extra routes from user config
|
||||
if (defineExtraRoutes) {
|
||||
const extraRoutes = defineRoutes(defineExtraRoutes);
|
||||
for (const key of Object.keys(extraRoutes)) {
|
||||
const route = extraRoutes[key];
|
||||
routeManifest[route.id] = {
|
||||
...route,
|
||||
parentId: route.parentId || undefined,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(defineExtraRoutesQueue)) {
|
||||
defineExtraRoutesQueue.forEach((defineExtraRoutes) => {
|
||||
if (defineExtraRoutes) {
|
||||
const extraRoutes = defineRoutes(
|
||||
defineExtraRoutes,
|
||||
{
|
||||
routeManifest,
|
||||
nestedRouteManifest: formatNestedRouteManifest(routeManifest),
|
||||
},
|
||||
);
|
||||
for (const key of Object.keys(extraRoutes)) {
|
||||
const route = extraRoutes[key];
|
||||
routeManifest[route.id] = {
|
||||
...route,
|
||||
parentId: route.parentId || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add routes by routes config.
|
||||
@ -86,7 +116,7 @@ export function generateRouteManifest(
|
||||
export function parseRoute(routeItem: RouteItem, parentId?: string, parentPath?: string) {
|
||||
const routes = [];
|
||||
const { path: routePath, component, children } = routeItem;
|
||||
const id = createRouteId(component);
|
||||
const id = createRouteId(component, routePath, parentPath);
|
||||
let index;
|
||||
const currentPath = path.join(parentPath || '/', routePath).split(path.sep).join('/');
|
||||
const isRootPath = currentPath === '/';
|
||||
@ -101,7 +131,7 @@ export function parseRoute(routeItem: RouteItem, parentId?: string, parentPath?:
|
||||
id,
|
||||
parentId,
|
||||
file: component,
|
||||
componentName: createComponentName(id),
|
||||
componentName: createComponentName(component),
|
||||
layout: !!children,
|
||||
};
|
||||
routes.push(route);
|
||||
@ -128,6 +158,7 @@ export function formatNestedRouteManifest(routeManifest: RouteManifest, parentId
|
||||
|
||||
function defineConventionalRoutes(
|
||||
rootDir: string,
|
||||
options: DefineRoutesOptions,
|
||||
ignoredFilePatterns?: string[],
|
||||
): RouteManifest {
|
||||
const files: { [routeId: string]: string } = {};
|
||||
@ -143,40 +174,41 @@ function defineConventionalRoutes(
|
||||
}
|
||||
|
||||
if (isRouteModuleFile(file)) {
|
||||
let routeId = createRouteId(file);
|
||||
files[routeId] = file;
|
||||
let fileId = createFileId(file);
|
||||
files[fileId] = file;
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const routeIds = Object.keys(files).sort(byLongestFirst);
|
||||
const fileIds = Object.keys(files).sort(byLongestFirst);
|
||||
|
||||
const uniqueRoutes = new Map<string, string>();
|
||||
|
||||
// 2. recurse through all routes using the public defineRoutes() API
|
||||
function defineNestedRoutes(
|
||||
defineRoute: DefineRouteFunction,
|
||||
options: DefineRoutesOptions,
|
||||
parentId?: string,
|
||||
): void {
|
||||
const childRouteIds = routeIds.filter((id) => {
|
||||
const parentRouteId = findParentRouteId(routeIds, id);
|
||||
return parentRouteId === parentId;
|
||||
const childFileIds = fileIds.filter((id) => {
|
||||
const parentFileId = findParentFileId(fileIds, id);
|
||||
return parentFileId === parentId;
|
||||
});
|
||||
|
||||
for (let routeId of childRouteIds) {
|
||||
for (let fileId of childFileIds) {
|
||||
const parentRoutePath = removeLastLayoutStrFromId(parentId) || '';
|
||||
const routePath: string | undefined = createRoutePath(
|
||||
// parentRoutePath = 'home', routeId = 'home/me', the new routeId is 'me'
|
||||
// in order to escape the child route path is absolute path
|
||||
routeId.slice(parentRoutePath.length + (parentRoutePath ? 1 : 0)),
|
||||
fileId.slice(parentRoutePath.length + (parentRoutePath ? 1 : 0)),
|
||||
);
|
||||
const routeFilePath = normalizeSlashes(path.join('src', 'pages', files[routeId]));
|
||||
const routeFilePath = normalizeSlashes(path.join('src', 'pages', files[fileId]));
|
||||
if (RegExp(`[^${validRouteChar.join('')}]+`).test(routePath)) {
|
||||
throw new Error(`invalid character in '${routeFilePath}'. Only support char: ${validRouteChar.join(', ')}`);
|
||||
}
|
||||
const isIndexRoute = routeId === 'index' || routeId.endsWith('/index');
|
||||
const fullPath = createRoutePath(routeId);
|
||||
const isIndexRoute = fileId === 'index' || fileId.endsWith('/index');
|
||||
const fullPath = createRoutePath(fileId);
|
||||
const uniqueRouteId = (fullPath || '') + (isIndexRoute ? '?index' : '');
|
||||
|
||||
if (uniqueRouteId) {
|
||||
@ -186,33 +218,33 @@ function defineConventionalRoutes(
|
||||
conflicts with route ${JSON.stringify(uniqueRoutes.get(uniqueRouteId))}`,
|
||||
);
|
||||
} else {
|
||||
uniqueRoutes.set(uniqueRouteId, routeId);
|
||||
uniqueRoutes.set(uniqueRouteId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isIndexRoute) {
|
||||
let invalidChildRoutes = routeIds.filter(
|
||||
(id) => findParentRouteId(routeIds, id) === routeId,
|
||||
let invalidChildRoutes = fileIds.filter(
|
||||
(id) => findParentFileId(fileIds, id) === fileId,
|
||||
);
|
||||
|
||||
if (invalidChildRoutes.length > 0) {
|
||||
throw new Error(
|
||||
`Child routes are not allowed in index routes. Please remove child routes of ${routeId}`,
|
||||
`Child routes are not allowed in index routes. Please remove child routes of ${fileId}`,
|
||||
);
|
||||
}
|
||||
|
||||
defineRoute(routePath, files[routeId], {
|
||||
defineRoute(routePath, files[fileId], {
|
||||
index: true,
|
||||
});
|
||||
} else {
|
||||
defineRoute(routePath, files[routeId], () => {
|
||||
defineNestedRoutes(defineRoute, routeId);
|
||||
defineRoute(routePath, files[fileId], () => {
|
||||
defineNestedRoutes(defineRoute, options, fileId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defineRoutes(defineNestedRoutes);
|
||||
return defineRoutes(defineNestedRoutes, options);
|
||||
}
|
||||
|
||||
const escapeStart = '[';
|
||||
@ -284,7 +316,7 @@ export function createRoutePath(routeId: string): string | undefined {
|
||||
return result || undefined;
|
||||
}
|
||||
|
||||
function findParentRouteId(
|
||||
function findParentFileId(
|
||||
routeIds: string[],
|
||||
childRouteId: string,
|
||||
): string | undefined {
|
||||
@ -315,16 +347,3 @@ function visitFiles(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* remove `/layout` str if the routeId has it
|
||||
*
|
||||
* 'layout' -> ''
|
||||
* 'About/layout' -> 'About'
|
||||
* 'About/layout/index' -> 'About/layout/index'
|
||||
*/
|
||||
function removeLastLayoutStrFromId(id?: string) {
|
||||
const layoutStrs = ['/layout', 'layout'];
|
||||
const currentLayoutStr = layoutStrs.find(layoutStr => id?.endsWith(layoutStr));
|
||||
return currentLayoutStr ? id.slice(0, id.length - currentLayoutStr.length) : id;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// based on https://github.com/remix-run/remix/blob/main/packages/remix-dev/config/routes.ts
|
||||
|
||||
import { win32 } from 'path';
|
||||
import { win32, join } from 'path';
|
||||
|
||||
export interface ConfigRoute {
|
||||
/**
|
||||
@ -82,8 +82,19 @@ export interface NestedRouteManifest extends ConfigRoute {
|
||||
children?: ConfigRoute[];
|
||||
}
|
||||
|
||||
export interface DefineRoutesOptions {
|
||||
routeManifest: RouteManifest;
|
||||
nestedRouteManifest: NestedRouteManifest[];
|
||||
}
|
||||
|
||||
export type DefineExtraRoutes = (
|
||||
defineRoute: DefineRouteFunction,
|
||||
options: DefineRoutesOptions,
|
||||
) => void;
|
||||
|
||||
export function defineRoutes(
|
||||
callback: (defineRoute: DefineRouteFunction) => void,
|
||||
callback: (defineRoute: DefineRouteFunction, options: DefineRoutesOptions) => void,
|
||||
options: DefineRoutesOptions,
|
||||
) {
|
||||
const routes: RouteManifest = Object.create(null);
|
||||
const parentRoutes: ConfigRoute[] = [];
|
||||
@ -108,18 +119,19 @@ export function defineRoutes(
|
||||
// route(path, file, options)
|
||||
options = optionsOrChildren || {};
|
||||
}
|
||||
const parentRoute = parentRoutes.length > 0
|
||||
? parentRoutes[parentRoutes.length - 1]
|
||||
: undefined;
|
||||
|
||||
const id = createRouteId(file, path, parentRoute?.path, options.index);
|
||||
|
||||
const id = createRouteId(file);
|
||||
const route: ConfigRoute = {
|
||||
path,
|
||||
index: options.index ? true : undefined,
|
||||
id,
|
||||
parentId:
|
||||
parentRoutes.length > 0
|
||||
? parentRoutes[parentRoutes.length - 1].id
|
||||
: undefined,
|
||||
parentId: parentRoute ? parentRoute.id : undefined,
|
||||
file,
|
||||
componentName: createComponentName(id),
|
||||
componentName: createComponentName(file),
|
||||
layout: id.endsWith('layout'),
|
||||
};
|
||||
|
||||
@ -132,14 +144,27 @@ export function defineRoutes(
|
||||
}
|
||||
};
|
||||
|
||||
callback(defineRoute);
|
||||
callback(defineRoute, options);
|
||||
|
||||
alreadyReturned = true;
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
export function createRouteId(file: string) {
|
||||
export function createRouteId(
|
||||
file: string,
|
||||
path?: string,
|
||||
parentPath?: string,
|
||||
index?: boolean,
|
||||
) {
|
||||
return normalizeSlashes(join(
|
||||
parentPath || '',
|
||||
path || (index ? '/' : ''),
|
||||
stripFileExtension(file).endsWith('layout') ? 'layout' : '',
|
||||
));
|
||||
}
|
||||
|
||||
export function createFileId(file: string) {
|
||||
return normalizeSlashes(stripFileExtension(file));
|
||||
}
|
||||
|
||||
@ -151,8 +176,21 @@ function stripFileExtension(file: string) {
|
||||
return file.replace(/\.[a-z0-9]+$/i, '');
|
||||
}
|
||||
|
||||
export function createComponentName(id: string) {
|
||||
return id.split('/')
|
||||
export function createComponentName(file: string) {
|
||||
return createFileId(file).split('/')
|
||||
.map((item: string) => item.toLowerCase())
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* remove `/layout` str if the routeId has it
|
||||
*
|
||||
* 'layout' -> ''
|
||||
* 'About/layout' -> 'About'
|
||||
* 'About/layout/index' -> 'About/layout/index'
|
||||
*/
|
||||
export function removeLastLayoutStrFromId(id?: string) {
|
||||
const layoutStrs = ['/layout', 'layout'];
|
||||
const currentLayoutStr = layoutStrs.find(layoutStr => id?.endsWith(layoutStr));
|
||||
return currentLayoutStr ? id.slice(0, id.length - currentLayoutStr.length) : id;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ exports[`generateRouteManifest function > layout-routes 1`] = `
|
||||
{
|
||||
"componentName": "blog-index",
|
||||
"file": "blog/index.tsx",
|
||||
"id": "blog/index",
|
||||
"id": "blog",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
@ -14,7 +14,7 @@ exports[`generateRouteManifest function > layout-routes 1`] = `
|
||||
{
|
||||
"componentName": "blog-$id",
|
||||
"file": "blog/$id.tsx",
|
||||
"id": "blog/$id",
|
||||
"id": "blog/:id",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
@ -32,7 +32,7 @@ exports[`generateRouteManifest function > layout-routes 1`] = `
|
||||
{
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
|
@ -2,19 +2,28 @@
|
||||
|
||||
exports[`generateRouteManifest function > basic-routes 1`] = `
|
||||
{
|
||||
"About/index": {
|
||||
"/": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"About": {
|
||||
"componentName": "about-index",
|
||||
"file": "About/index.tsx",
|
||||
"id": "About/index",
|
||||
"id": "About",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": "About",
|
||||
},
|
||||
"About/me/index": {
|
||||
"About/me": {
|
||||
"componentName": "about-me-index",
|
||||
"file": "About/me/index.tsx",
|
||||
"id": "About/me/index",
|
||||
"id": "About/me",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
@ -29,15 +38,6 @@ exports[`generateRouteManifest function > basic-routes 1`] = `
|
||||
"parentId": "layout",
|
||||
"path": "home",
|
||||
},
|
||||
"index": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"layout": {
|
||||
"componentName": "layout",
|
||||
"file": "layout.tsx",
|
||||
@ -52,19 +52,28 @@ exports[`generateRouteManifest function > basic-routes 1`] = `
|
||||
|
||||
exports[`generateRouteManifest function > define-extra-routes 1`] = `
|
||||
{
|
||||
"About/index": {
|
||||
"/": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"/about-me": {
|
||||
"componentName": "about-index",
|
||||
"file": "About/index.tsx",
|
||||
"id": "About/index",
|
||||
"id": "/about-me",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
"path": "/about-me",
|
||||
},
|
||||
"About/me/index": {
|
||||
"About/me": {
|
||||
"componentName": "about-me-index",
|
||||
"file": "About/me/index.tsx",
|
||||
"id": "About/me/index",
|
||||
"id": "About/me",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
@ -79,15 +88,6 @@ exports[`generateRouteManifest function > define-extra-routes 1`] = `
|
||||
"parentId": "layout",
|
||||
"path": "home",
|
||||
},
|
||||
"index": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"layout": {
|
||||
"componentName": "layout",
|
||||
"file": "layout.tsx",
|
||||
@ -102,10 +102,10 @@ exports[`generateRouteManifest function > define-extra-routes 1`] = `
|
||||
|
||||
exports[`generateRouteManifest function > doc-delimeters-routes 1`] = `
|
||||
{
|
||||
"home.news": {
|
||||
"home/news": {
|
||||
"componentName": "home.news",
|
||||
"file": "home.news.tsx",
|
||||
"id": "home.news",
|
||||
"id": "home/news",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
@ -125,6 +125,15 @@ exports[`generateRouteManifest function > doc-delimeters-routes 1`] = `
|
||||
|
||||
exports[`generateRouteManifest function > dynamic-routes 1`] = `
|
||||
{
|
||||
"/": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
"path": undefined,
|
||||
},
|
||||
"about": {
|
||||
"componentName": "about",
|
||||
"file": "about.tsx",
|
||||
@ -134,51 +143,42 @@ exports[`generateRouteManifest function > dynamic-routes 1`] = `
|
||||
"parentId": undefined,
|
||||
"path": "about",
|
||||
},
|
||||
"blog/$id": {
|
||||
"componentName": "blog-$id",
|
||||
"file": "blog/$id.tsx",
|
||||
"id": "blog/$id",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
"path": "blog/:id",
|
||||
},
|
||||
"blog/index": {
|
||||
"blog": {
|
||||
"componentName": "blog-index",
|
||||
"file": "blog/index.tsx",
|
||||
"id": "blog/index",
|
||||
"id": "blog",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
"path": "blog",
|
||||
},
|
||||
"index": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"index": true,
|
||||
"blog/:id": {
|
||||
"componentName": "blog-$id",
|
||||
"file": "blog/$id.tsx",
|
||||
"id": "blog/:id",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
"path": undefined,
|
||||
"path": "blog/:id",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`generateRouteManifest function > escape-routes 1`] = `
|
||||
{
|
||||
"1[.pdf]": {
|
||||
"1.pdf": {
|
||||
"componentName": "1[.pdf]",
|
||||
"file": "1[.pdf].tsx",
|
||||
"id": "1[.pdf]",
|
||||
"id": "1.pdf",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
"path": "1.pdf",
|
||||
},
|
||||
"[index]": {
|
||||
"index": {
|
||||
"componentName": "[index]",
|
||||
"file": "[index].tsx",
|
||||
"id": "[index]",
|
||||
"id": "index",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
@ -189,10 +189,19 @@ exports[`generateRouteManifest function > escape-routes 1`] = `
|
||||
|
||||
exports[`generateRouteManifest function > ignore-routes 1`] = `
|
||||
{
|
||||
"About/me/index": {
|
||||
"/": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"About/me": {
|
||||
"componentName": "about-me-index",
|
||||
"file": "About/me/index.tsx",
|
||||
"id": "About/me/index",
|
||||
"id": "About/me",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
@ -207,15 +216,6 @@ exports[`generateRouteManifest function > ignore-routes 1`] = `
|
||||
"parentId": "layout",
|
||||
"path": "home",
|
||||
},
|
||||
"index": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"layout": {
|
||||
"componentName": "layout",
|
||||
"file": "layout.tsx",
|
||||
@ -230,6 +230,15 @@ exports[`generateRouteManifest function > ignore-routes 1`] = `
|
||||
|
||||
exports[`generateRouteManifest function > layout-routes 1`] = `
|
||||
{
|
||||
"/": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"about": {
|
||||
"componentName": "about",
|
||||
"file": "about.tsx",
|
||||
@ -239,24 +248,24 @@ exports[`generateRouteManifest function > layout-routes 1`] = `
|
||||
"parentId": "layout",
|
||||
"path": "about",
|
||||
},
|
||||
"blog/$id": {
|
||||
"componentName": "blog-$id",
|
||||
"file": "blog/$id.tsx",
|
||||
"id": "blog/$id",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "blog/layout",
|
||||
"path": ":id",
|
||||
},
|
||||
"blog/index": {
|
||||
"blog/": {
|
||||
"componentName": "blog-index",
|
||||
"file": "blog/index.tsx",
|
||||
"id": "blog/index",
|
||||
"id": "blog/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "blog/layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"blog/:id": {
|
||||
"componentName": "blog-$id",
|
||||
"file": "blog/$id.tsx",
|
||||
"id": "blog/:id",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "blog/layout",
|
||||
"path": ":id",
|
||||
},
|
||||
"blog/layout": {
|
||||
"componentName": "blog-layout",
|
||||
"file": "blog/layout.tsx",
|
||||
@ -266,42 +275,24 @@ exports[`generateRouteManifest function > layout-routes 1`] = `
|
||||
"parentId": "layout",
|
||||
"path": "blog",
|
||||
},
|
||||
"home/index": {
|
||||
"home/": {
|
||||
"componentName": "home-index",
|
||||
"file": "home/index.tsx",
|
||||
"id": "home/index",
|
||||
"id": "home/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "home/layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"home/layout": {
|
||||
"componentName": "home-layout",
|
||||
"file": "home/layout.tsx",
|
||||
"id": "home/layout",
|
||||
"index": undefined,
|
||||
"layout": true,
|
||||
"parentId": "layout",
|
||||
"path": "home",
|
||||
},
|
||||
"home/layout/index": {
|
||||
"componentName": "home-layout-index",
|
||||
"file": "home/layout/index.tsx",
|
||||
"id": "home/layout/index",
|
||||
"id": "home/layout",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"layout": true,
|
||||
"parentId": "home/layout",
|
||||
"path": "layout",
|
||||
},
|
||||
"index": {
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"path": undefined,
|
||||
},
|
||||
"layout": {
|
||||
"componentName": "layout",
|
||||
"file": "layout.tsx",
|
||||
@ -325,10 +316,10 @@ exports[`generateRouteManifest function > nested-routes 1`] = `
|
||||
"parentId": undefined,
|
||||
"path": "a/b/c",
|
||||
},
|
||||
"d.e.f": {
|
||||
"d/e/f": {
|
||||
"componentName": "d.e.f",
|
||||
"file": "d.e.f.tsx",
|
||||
"id": "d.e.f",
|
||||
"id": "d/e/f",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
@ -339,10 +330,10 @@ exports[`generateRouteManifest function > nested-routes 1`] = `
|
||||
|
||||
exports[`generateRouteManifest function > splat-routes 1`] = `
|
||||
{
|
||||
"$": {
|
||||
"*": {
|
||||
"componentName": "$",
|
||||
"file": "$.tsx",
|
||||
"id": "$",
|
||||
"id": "*",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
|
@ -5,7 +5,7 @@ exports[`parseRoute function > basic-routes 1`] = `
|
||||
{
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": undefined,
|
||||
@ -19,7 +19,7 @@ exports[`parseRoute function > nested layout 1`] = `
|
||||
{
|
||||
"componentName": "layout",
|
||||
"file": "layout.tsx",
|
||||
"id": "layout",
|
||||
"id": "/layout",
|
||||
"index": undefined,
|
||||
"layout": true,
|
||||
"parentId": undefined,
|
||||
@ -28,37 +28,37 @@ exports[`parseRoute function > nested layout 1`] = `
|
||||
{
|
||||
"componentName": "home",
|
||||
"file": "home.tsx",
|
||||
"id": "home",
|
||||
"id": "/home",
|
||||
"index": undefined,
|
||||
"layout": true,
|
||||
"parentId": "layout",
|
||||
"parentId": "/layout",
|
||||
"path": "home",
|
||||
},
|
||||
{
|
||||
"componentName": "home1",
|
||||
"file": "home1.tsx",
|
||||
"id": "home1",
|
||||
"id": "/home/1",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "home",
|
||||
"parentId": "/home",
|
||||
"path": "1",
|
||||
},
|
||||
{
|
||||
"componentName": "home2",
|
||||
"file": "home2.tsx",
|
||||
"id": "home2",
|
||||
"id": "/home/2",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "home",
|
||||
"parentId": "/home",
|
||||
"path": "2",
|
||||
},
|
||||
{
|
||||
"componentName": "about",
|
||||
"file": "about.tsx",
|
||||
"id": "about",
|
||||
"id": "/about",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"parentId": "/layout",
|
||||
"path": "about",
|
||||
},
|
||||
]
|
||||
@ -69,7 +69,7 @@ exports[`parseRoute function > with layout 1`] = `
|
||||
{
|
||||
"componentName": "layout",
|
||||
"file": "layout.tsx",
|
||||
"id": "layout",
|
||||
"id": "/layout",
|
||||
"index": undefined,
|
||||
"layout": true,
|
||||
"parentId": undefined,
|
||||
@ -78,28 +78,28 @@ exports[`parseRoute function > with layout 1`] = `
|
||||
{
|
||||
"componentName": "home",
|
||||
"file": "home.tsx",
|
||||
"id": "home",
|
||||
"id": "/home",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"parentId": "/layout",
|
||||
"path": "home",
|
||||
},
|
||||
{
|
||||
"componentName": "about",
|
||||
"file": "about.tsx",
|
||||
"id": "about",
|
||||
"id": "/about",
|
||||
"index": undefined,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"parentId": "/layout",
|
||||
"path": "about",
|
||||
},
|
||||
{
|
||||
"componentName": "index",
|
||||
"file": "index.tsx",
|
||||
"id": "index",
|
||||
"id": "/",
|
||||
"index": true,
|
||||
"layout": false,
|
||||
"parentId": "layout",
|
||||
"parentId": "/layout",
|
||||
"path": "/",
|
||||
},
|
||||
]
|
||||
|
@ -45,9 +45,9 @@ describe('generateRouteManifest function', () => {
|
||||
const routeManifest = generateRouteManifest(
|
||||
path.join(fixturesDir, 'basic-routes'),
|
||||
['About/index.tsx'],
|
||||
(defineRoute) => {
|
||||
[(defineRoute) => {
|
||||
defineRoute('/about-me', 'About/index.tsx');
|
||||
},
|
||||
}],
|
||||
);
|
||||
expect(routeManifest).toMatchSnapshot();
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { ServerResponse } from 'http';
|
||||
import type { ServerResponse, IncomingMessage } from 'http';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOMServer from 'react-dom/server';
|
||||
import { parsePath } from 'react-router-dom';
|
||||
@ -55,9 +55,11 @@ interface Piper {
|
||||
pipe: NodeWritablePiper;
|
||||
fallback: Function;
|
||||
}
|
||||
interface RenderResult {
|
||||
interface Response {
|
||||
statusCode?: number;
|
||||
statusText?: string;
|
||||
value?: string | Piper;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -95,11 +97,11 @@ export async function renderToEntry(
|
||||
export async function renderToHTML(
|
||||
requestContext: ServerContext,
|
||||
renderOptions: RenderOptions,
|
||||
): Promise<RenderResult> {
|
||||
): Promise<Response> {
|
||||
const result = await doRender(requestContext, renderOptions);
|
||||
const { value } = result;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === 'string' || typeof value === 'undefined') {
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -110,6 +112,9 @@ export async function renderToHTML(
|
||||
|
||||
return {
|
||||
value: entryStr,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
@ -127,13 +132,13 @@ export async function renderToHTML(
|
||||
* Render and send the result to ServerResponse.
|
||||
*/
|
||||
export async function renderToResponse(requestContext: ServerContext, renderOptions: RenderOptions) {
|
||||
const { res } = requestContext;
|
||||
const { req, res } = requestContext;
|
||||
const result = await doRender(requestContext, renderOptions);
|
||||
|
||||
const { value } = result;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
sendResult(res, result);
|
||||
if (typeof value === 'string' || typeof value === 'undefined') {
|
||||
sendResponse(req, res, result);
|
||||
} else {
|
||||
const { pipe, fallback } = value;
|
||||
|
||||
@ -152,7 +157,7 @@ export async function renderToResponse(requestContext: ServerContext, renderOpti
|
||||
console.error('PipeToResponse onShellError, downgrade to CSR.');
|
||||
console.error(err);
|
||||
const result = await fallback();
|
||||
sendResult(res, result);
|
||||
sendResponse(req, res, result);
|
||||
resolve();
|
||||
},
|
||||
onError: async (err) => {
|
||||
@ -169,21 +174,29 @@ export async function renderToResponse(requestContext: ServerContext, renderOpti
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send string result to ServerResponse.
|
||||
*/
|
||||
async function sendResult(res: ServerResponse, result: RenderResult) {
|
||||
res.statusCode = result.statusCode;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(result.value);
|
||||
async function sendResponse(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
response: Response,
|
||||
) {
|
||||
res.statusCode = response.statusCode;
|
||||
res.statusMessage = response.statusText;
|
||||
Object.entries(response.headers || {}).forEach(([name, value]) => {
|
||||
res.setHeader(name, value);
|
||||
});
|
||||
if (response.value && req.method !== 'HEAD') {
|
||||
res.end(response.value);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
function needRevalidate(matchedRoutes: RouteMatch[]) {
|
||||
return matchedRoutes.some(({ route }) => route.exports.includes('dataLoader') && route.exports.includes('staticDataLoader'));
|
||||
}
|
||||
|
||||
async function doRender(serverContext: ServerContext, renderOptions: RenderOptions): Promise<RenderResult> {
|
||||
const { req } = serverContext;
|
||||
async function doRender(serverContext: ServerContext, renderOptions: RenderOptions): Promise<Response> {
|
||||
const { req, res } = serverContext;
|
||||
const {
|
||||
app,
|
||||
basename,
|
||||
@ -226,6 +239,7 @@ async function doRender(serverContext: ServerContext, renderOptions: RenderOptio
|
||||
if (runtimeModules.statics) {
|
||||
await Promise.all(runtimeModules.statics.map(m => runtime.loadModule(m)).filter(Boolean));
|
||||
}
|
||||
|
||||
// don't need to execute getAppData in CSR
|
||||
if (!documentOnly) {
|
||||
try {
|
||||
@ -245,7 +259,7 @@ async function doRender(serverContext: ServerContext, renderOptions: RenderOptio
|
||||
if (documentOnly) {
|
||||
return renderDocument({ matches, routePath, routes, renderOptions });
|
||||
} else if (!matches.length) {
|
||||
return render404();
|
||||
return handleNotFoundResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
@ -266,6 +280,31 @@ async function doRender(serverContext: ServerContext, renderOptions: RenderOptio
|
||||
if (runtimeModules.commons) {
|
||||
await Promise.all(runtimeModules.commons.map(m => runtime.loadModule(m)).filter(Boolean));
|
||||
}
|
||||
/**
|
||||
Plugin may register response handlers, for example:
|
||||
```
|
||||
addResponseHandler((req) => {
|
||||
if (redirect) {
|
||||
return {
|
||||
statusCode: 302,
|
||||
statusText: 'Found',
|
||||
headers: {
|
||||
location: '/redirect',
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
*/
|
||||
const responseHandlers = runtime.getResponseHandlers();
|
||||
for (const responseHandler of responseHandlers) {
|
||||
if (typeof responseHandler === 'function') {
|
||||
const response = await responseHandler(req, res);
|
||||
if (response) {
|
||||
return response as Response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await renderServerEntry({
|
||||
runtime,
|
||||
@ -283,9 +322,9 @@ async function doRender(serverContext: ServerContext, renderOptions: RenderOptio
|
||||
}
|
||||
|
||||
// https://github.com/ice-lab/ice-next/issues/133
|
||||
function render404(): RenderResult {
|
||||
function handleNotFoundResponse(): Response {
|
||||
return {
|
||||
value: 'Not Found',
|
||||
statusText: 'Not Found',
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
@ -306,7 +345,7 @@ async function renderServerEntry(
|
||||
location,
|
||||
renderOptions,
|
||||
}: RenderServerEntry,
|
||||
): Promise<RenderResult> {
|
||||
): Promise<Response> {
|
||||
const { Document } = renderOptions;
|
||||
const appContext = runtime.getAppContext();
|
||||
const { routes, routePath, loaderData, basename } = appContext;
|
||||
@ -356,7 +395,7 @@ interface RenderDocumentOptions {
|
||||
/**
|
||||
* Render Document for CSR.
|
||||
*/
|
||||
function renderDocument(options: RenderDocumentOptions): RenderResult {
|
||||
function renderDocument(options: RenderDocumentOptions): Response {
|
||||
const {
|
||||
matches,
|
||||
renderOptions,
|
||||
@ -415,6 +454,9 @@ function renderDocument(options: RenderDocumentOptions): RenderResult {
|
||||
|
||||
return {
|
||||
value: `<!DOCTYPE html>${htmlStr}`,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import type { ComponentType } from 'react';
|
||||
import { routerHistory as history } from './history.js';
|
||||
import type {
|
||||
Renderer,
|
||||
AppContext,
|
||||
@ -15,6 +16,7 @@ import type {
|
||||
SetRender,
|
||||
AppRouterProps,
|
||||
ComponentWithChildren,
|
||||
ResponseHandler,
|
||||
} from './types.js';
|
||||
import { useData, useConfig } from './RouteContext.js';
|
||||
import { useAppContext } from './AppContext.js';
|
||||
@ -32,6 +34,8 @@ class Runtime {
|
||||
|
||||
private render: Renderer;
|
||||
|
||||
private responseHandlers: ResponseHandler[];
|
||||
|
||||
public constructor(appContext: AppContext, runtimeOptions?: Record<string, any>) {
|
||||
this.AppProvider = [];
|
||||
this.appContext = appContext;
|
||||
@ -42,6 +46,7 @@ class Runtime {
|
||||
};
|
||||
this.RouteWrappers = [];
|
||||
this.runtimeOptions = runtimeOptions;
|
||||
this.responseHandlers = [];
|
||||
}
|
||||
|
||||
public getAppContext = () => {
|
||||
@ -66,6 +71,8 @@ class Runtime {
|
||||
public async loadModule(module: RuntimePlugin | StaticRuntimePlugin | CommonJsRuntime) {
|
||||
let runtimeAPI: RuntimeAPI = {
|
||||
addProvider: this.addProvider,
|
||||
addResponseHandler: this.addResponseHandler,
|
||||
getResponseHandlers: this.getResponseHandlers,
|
||||
getAppRouter: this.getAppRouter,
|
||||
setRender: this.setRender,
|
||||
addWrapper: this.addWrapper,
|
||||
@ -74,6 +81,7 @@ class Runtime {
|
||||
useData,
|
||||
useConfig,
|
||||
useAppContext,
|
||||
history,
|
||||
};
|
||||
|
||||
const runtimeModule = ((module as CommonJsRuntime).default || module) as RuntimePlugin;
|
||||
@ -113,6 +121,14 @@ class Runtime {
|
||||
public setAppRouter: SetAppRouter = (AppRouter) => {
|
||||
this.AppRouter = AppRouter;
|
||||
};
|
||||
|
||||
public addResponseHandler = (handler: ResponseHandler) => {
|
||||
this.responseHandlers.push(handler);
|
||||
};
|
||||
|
||||
public getResponseHandlers = () => {
|
||||
return this.responseHandlers;
|
||||
};
|
||||
}
|
||||
|
||||
export default Runtime;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { InitialEntry, AgnosticRouteObject, Location } from '@remix-run/router';
|
||||
import type { InitialEntry, AgnosticRouteObject, Location, History } from '@remix-run/router';
|
||||
import type { ComponentType, PropsWithChildren } from 'react';
|
||||
import type { HydrationOptions, Root } from 'react-dom/client';
|
||||
import type { Params, RouteObject } from 'react-router-dom';
|
||||
@ -154,12 +154,18 @@ export interface RouteWrapperConfig {
|
||||
|
||||
export type AppProvider = ComponentWithChildren<any>;
|
||||
export type RouteWrapper = ComponentType<any>;
|
||||
export type ResponseHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => any | Promise<any>;
|
||||
|
||||
export type SetAppRouter = (AppRouter: ComponentType<AppRouterProps>) => void;
|
||||
export type GetAppRouter = () => AppProvider;
|
||||
export type AddProvider = (Provider: AppProvider) => void;
|
||||
export type SetRender = (render: Renderer) => void;
|
||||
export type AddWrapper = (wrapper: RouteWrapper, forLayout?: boolean) => void;
|
||||
export type AddResponseHandler = (handler: ResponseHandler) => void;
|
||||
export type GetResponseHandlers = () => ResponseHandler[];
|
||||
|
||||
export interface RouteModules {
|
||||
[routeId: string]: ComponentModule;
|
||||
@ -183,12 +189,15 @@ export interface RuntimeAPI {
|
||||
setAppRouter?: SetAppRouter;
|
||||
getAppRouter: GetAppRouter;
|
||||
addProvider: AddProvider;
|
||||
addResponseHandler: AddResponseHandler;
|
||||
getResponseHandlers: GetResponseHandlers;
|
||||
setRender: SetRender;
|
||||
addWrapper: AddWrapper;
|
||||
appContext: AppContext;
|
||||
useData: UseData;
|
||||
useConfig: UseConfig;
|
||||
useAppContext: UseAppContext;
|
||||
history: History;
|
||||
}
|
||||
|
||||
export interface StaticRuntimeAPI {
|
||||
|
1359
pnpm-lock.yaml
1359
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
44
tests/integration/with-i18n.test.ts
Normal file
44
tests/integration/with-i18n.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import * as path from 'path';
|
||||
import glob from 'glob';
|
||||
import { expect, test, describe, afterAll } from 'vitest';
|
||||
import { buildFixture, setupBrowser } from '../utils/build';
|
||||
import type { Page } from '../utils/browser';
|
||||
import type Browser from '../utils/browser';
|
||||
|
||||
const example = 'with-i18n';
|
||||
|
||||
describe(`build ${example}`, () => {
|
||||
let page: Page;
|
||||
let browser: Browser;
|
||||
|
||||
test('generate html files with locales', async () => {
|
||||
await buildFixture(example);
|
||||
const res = await setupBrowser({ example, disableJS: false });
|
||||
page = res.page;
|
||||
browser = res.browser;
|
||||
const distDir = path.join(__dirname, `../../examples/${example}/build`);
|
||||
const htmlFiles = glob.sync('**/*.html', { cwd: distDir });
|
||||
|
||||
expect(htmlFiles).toEqual([
|
||||
'blog.html',
|
||||
'blog/a.html',
|
||||
'en-US.html',
|
||||
'en-US/blog.html',
|
||||
'en-US/blog/a.html',
|
||||
'index.html',
|
||||
]);
|
||||
});
|
||||
|
||||
test('visit / page and get the zh-CN locale page', async () => {
|
||||
expect(await page.$$text('#button')).toStrictEqual(['普通按钮']);
|
||||
});
|
||||
|
||||
test('visit /en-US page and get the en-US locale page', async () => {
|
||||
await page.push('/en-US.html');
|
||||
expect(await page.$$text('#button')).toStrictEqual(['Normal Button']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
});
|
@ -1,6 +1,380 @@
|
||||
---
|
||||
title: 国际化
|
||||
hide: true
|
||||
---
|
||||
|
||||
@TODO
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
ice.js 官方提供 i18n 国际化插件,支持在应用快速开启国际化能力。核心特性包括:
|
||||
|
||||
1. 支持自动处理和生成国际化路由
|
||||
2. 完美支持 SSR 和 SSG,以获得更好的 SEO 优化
|
||||
3. 支持自动重定向到偏好语言对应的页面
|
||||
4. 不耦合任何一个 i18n 库(流行的 React i18n 库有 [react-intl](https://formatjs.io/docs/getting-started/installation/)、[react-i18next](https://react.i18next.com/) 等),你可以选择任一国际化的库来为你的应用设置国际化
|
||||
|
||||
<details open>
|
||||
<summary>使用国际化插件的示例</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/alibaba/ice/tree/master/examples/with-i18n" target="_blank" rel="noopener noreferrer">
|
||||
with-i18n
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
:::tip
|
||||
|
||||
如果应用不需要使用国际化路由,你可以参考以下例子来让你的项目支持国际化:
|
||||
|
||||
- [with-antd5](https://github.com/alibaba/ice/tree/master/examples/with-antd5)
|
||||
- [with-fusion](https://github.com/alibaba/ice/tree/master/examples/with-fusion)
|
||||
|
||||
:::
|
||||
|
||||
## 快速开始
|
||||
|
||||
首先,我们需要在终端执行以下命令安装插件:
|
||||
|
||||
```bash
|
||||
$ npm i @ice/plugin-i18n -D
|
||||
```
|
||||
|
||||
然后在 `ice.config.mts` 中添加插件和选项:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from '@ice/app';
|
||||
import i18n from '@ice/plugin-i18n';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18n({
|
||||
locales: ['zh-CN', 'en-US', 'de'],
|
||||
defaultLocale: 'zh-CN',
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
上面的 `en-US` 和 `zh-CN` 是国际化语言的缩写,它们均遵循标准的 [UTS 语言标识符](https://www.unicode.org/reports/tr35/tr35-59/tr35.html#Identifiers)。比如:
|
||||
|
||||
- `zh-CN`:中文(中国)
|
||||
- `zh-HK`:中文(香港)
|
||||
- `en-US`:英文(美国)
|
||||
- `de`: 德文
|
||||
|
||||
## 国际化路由
|
||||
|
||||
国际化路由是指在页面路由地址中包含了当前页面的语言,一个国际化路由对应一个语言。
|
||||
|
||||
假设现在插件的选项配置是:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from '@ice/app';
|
||||
import i18n from '@ice/plugin-i18n';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18n({
|
||||
locales: ['zh-CN', 'en-US', 'nl-NL'],
|
||||
defaultLocale: 'zh-CN',
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
假设我们有一个页面 `src/pages/home.tsx`,那么将会一一对应自动生成以下的路由:
|
||||
|
||||
- `/home`:显示 `zh-CN` 语言,默认语言对应的路由不包含语言前缀
|
||||
- `/en-US/home`:显示 `en-US` 语言
|
||||
- `/nl-NL/home`:显示 `nl-NL` 语言
|
||||
|
||||
访问不同的路由,将会显示该语言对应页面内容。
|
||||
|
||||
## 获取语言信息
|
||||
|
||||
### `getLocales()`
|
||||
|
||||
`getAllLocales()` 用于获取当前应用支持的所有语言。
|
||||
|
||||
```ts
|
||||
import { getAllLocales } from 'ice';
|
||||
|
||||
console.log(getAllLocales()); // ['zh-CN', 'en-US']
|
||||
```
|
||||
|
||||
### `getDefaultLocale()`
|
||||
|
||||
`getDefaultLocale()` 用于获取应用配置的默认语言。
|
||||
|
||||
```ts
|
||||
import { getDefaultLocale } from 'ice';
|
||||
|
||||
console.log(getDefaultLocale()); // 'zh-CN'
|
||||
```
|
||||
|
||||
### `useLocale()`
|
||||
|
||||
在 Function 组件中使用 `useLocale()` Hook API,它的返回值是一个数组,包含两个值:
|
||||
|
||||
1. 当前页面的语言
|
||||
2. 一个 set 函数用于更新当前页面的语言。注意,默认情况下调用此 set 函数时候,同时会更新 Cookie 中 `ice_locale` 的值为当前页面的语言。这样,再次访问该页面时,从服务端请求能得知当前用户的之前设置的偏好语言,以便返回对应语言的页面内容。
|
||||
|
||||
|
||||
```tsx
|
||||
import { useLocale } from 'ice';
|
||||
|
||||
export default function Home() {
|
||||
const [locale, setLocale] = useLocale();
|
||||
|
||||
console.log('locale: ', locale); // 'en-US'
|
||||
return (
|
||||
<>
|
||||
{/* 切换语言为 zh-CN */}
|
||||
<div onClick={() => setLocale('zh-CN')}>Set zh-CN</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `withLocale()`
|
||||
|
||||
使用 `withLocale()` 方法包裹的 Class 组件,组件的 Props 会包含 `locale` 和 `setLocale()` 函数,可以查看和修改当前页面的语言。注意,默认情况下调用 `setLocale()`,会更新 Cookie 中 `ice_locale` 的值为当前页面的语言。这样,再次访问该页面时,从服务端请求能得知当前用户的之前设置的偏好语言,以便返回对应语言的页面内容。
|
||||
|
||||
```tsx
|
||||
import { withLocale } from 'ice';
|
||||
|
||||
function Home({ locale, setLocale }) {
|
||||
console.log('locale: ', locale); // 'en-US'
|
||||
return (
|
||||
<>
|
||||
{/* 切换语言为 zh-CN */}
|
||||
<div onClick={() => setLocale('zh-CN')}>Set zh-CN</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withLocale(Home);
|
||||
```
|
||||
|
||||
## 切换语言
|
||||
|
||||
推荐使用 `setLocale()` 方法配合 `<Link>` 组件或者 `useNavigate()` 方法进行语言切换:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="a" label="使用 <Link />">
|
||||
|
||||
```tsx
|
||||
import { useLocale, getAllLocales, Link, useLocation } from 'ice';
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const [activeLocale, setLocale] = useLocale();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p><b>Current locale: </b>{activeLocale}</p>
|
||||
|
||||
<b>Choose language: </b>
|
||||
<ul>
|
||||
{
|
||||
getAllLocales().map((locale: string) => {
|
||||
return (
|
||||
<li key={locale}>
|
||||
<Link
|
||||
to={location.pathname}
|
||||
onClick={() => setLocale(locale)}
|
||||
>
|
||||
{locale}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="b" label="使用 useNavigate()">
|
||||
|
||||
```tsx
|
||||
import { useLocale, useNavigate, useLocation } from 'ice';
|
||||
|
||||
export default function Layout() {
|
||||
const [, setLocale] = useLocale();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const switchToZHCN = () => {
|
||||
setLocale('zh-CN');
|
||||
navigate(location.pathname);
|
||||
}
|
||||
return (
|
||||
<main>
|
||||
<div onClick={switchToZHCN}>
|
||||
点我切换到中文
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 路由自动重定向
|
||||
|
||||
路由自动重定向是指,如果当前访问的页面是根路由(`/`),将会根据当前语言环境自动跳转到对应的国际化路由。
|
||||
|
||||
默认情况下,路由自动重定向的功能是关闭的。如果需要开启,则需要加入以下内容:
|
||||
|
||||
```diff
|
||||
import { defineConfig } from '@ice/app';
|
||||
import i18n from '@ice/plugin-i18n';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18n({
|
||||
locales: ['zh-CN', 'en-US', 'de'],
|
||||
defaultLocale: 'zh-CN',
|
||||
+ autoRedirect: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
其中,语言环境的识别顺序如下:
|
||||
|
||||
- `CSR`:cookie 中 `ice_locale` 的值 > `window.navigator.language` > `defaultLocale`
|
||||
- `SSR`:cookie 中 `ice_locale` 的值 > `Request Header` 中的 `Accept-Language` > `defaultLocale`
|
||||
|
||||
在部署阶段,路由自动重定向的功能需要配合 Node 中间件使用才能生效。比如:
|
||||
|
||||
```ts
|
||||
import express from 'express';
|
||||
import { renderToHTML } from './build/server/index.mjs';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.static('build', {}));
|
||||
|
||||
app.use(async (req, res) => {
|
||||
const { statusCode, statusText, headers, value: body } = await renderToHTML({ req, res });
|
||||
res.statusCode = statusCode;
|
||||
res.statusMessage = statusText;
|
||||
Object.entries((headers || {}) as Record<string, string>).forEach(([name, value]) => {
|
||||
res.setHeader(name, value);
|
||||
});
|
||||
if (body && req.method !== 'HEAD') {
|
||||
res.end(body);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 禁用 Cookie
|
||||
|
||||
在上面的章节中提到,用户设置的偏好语言是存放在 Cookie 中的 `ice_locale`,调用 `setLocale()` 时会更新到 Cookie 中,并且路由重定向和路由跳转的时候依赖 `ice_locale` 的值。
|
||||
|
||||
假设有这么一个场景,用户拒绝接受 Cookie,为了保护隐私,这样就不能把偏好语言写到 Cookie 中了。因此需要做以下的配置来禁用 Cookie:
|
||||
|
||||
```ts title="src/app.ts"
|
||||
import { defineI18nConfig } from '@ice/plugin-i18n/types';
|
||||
|
||||
export const i18nConfig = defineI18nConfig(() => ({
|
||||
// 可以是一个 function
|
||||
disabledCookie: () => {
|
||||
if (import.meta.renderer === 'client') {
|
||||
return window.localStorage.getItem('acceptCookie') === 'yes';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
// 也可以是 boolean 值
|
||||
// disabledCookie: true,
|
||||
}));
|
||||
```
|
||||
|
||||
这样,就禁用掉了 Cookie 的写入了。在切换语言的时候需要在 `state` 对象中显式传入即将要切换的新语言的值:
|
||||
|
||||
```tsx
|
||||
import { Link, useLocale } from 'ice';
|
||||
|
||||
export default function Home() {
|
||||
const [, setLocale] = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setLocale('zh-CN')}
|
||||
state={{ locale: 'zh-CN' }}
|
||||
>
|
||||
切换到 zh-CN
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## SSG
|
||||
|
||||
在开启 SSG 功能后,将根据配置的 `locales` 的值,在 `build` 阶段会生成不同语言对应的 HTML。
|
||||
|
||||
比如我们有以下的目录结构,包含 `about` 和 `index` 两个页面:
|
||||
|
||||
```md
|
||||
├── src/pages
|
||||
| ├── about.tsx
|
||||
| └── index.tsx
|
||||
```
|
||||
|
||||
假如插件的配置是:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from '@ice/app';
|
||||
import i18n from '@ice/plugin-i18n';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18n({
|
||||
locales: ['zh-CN', 'en-US'],
|
||||
defaultLocale: 'zh-CN',
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
那么将会生成 4 个 HTML 文件:
|
||||
|
||||
```md
|
||||
├── build
|
||||
| ├── about
|
||||
| | └── index.html
|
||||
| ├── en-US
|
||||
| | ├── about
|
||||
| | | └── index.html
|
||||
| | └── index.html
|
||||
| ├── index.html
|
||||
```
|
||||
|
||||
## 插件选项
|
||||
|
||||
### `locales`
|
||||
|
||||
- **类型:**`string[]`
|
||||
|
||||
用于声明该应用支持的语言。
|
||||
|
||||
### `defaultLocale`
|
||||
|
||||
- **类型:**`string`
|
||||
|
||||
声明该应用默认的语言。需要注意的是, `locales` 数组必须包含 `defaultLocale` 的值。
|
||||
|
||||
### `autoRedirect`
|
||||
|
||||
- **类型:**`boolean`
|
||||
- **默认值:**`false`
|
||||
|
||||
默认不会自动重定向到用户偏好语言对应的页面。如果设置为 `true`,在生产环境下,一般需要配合 Node 中间件一起使用才能生效。[详见](#路由自动重定向)
|
||||
|
Loading…
Reference in New Issue
Block a user