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:
luhc228 2023-04-24 10:18:53 +08:00 committed by GitHub
parent 3ede3c5a2a
commit 1c3d3fec6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 3184 additions and 325 deletions

View File

@ -0,0 +1,5 @@
---
'@ice/app': patch
---
feat: support add routes definition

View File

@ -0,0 +1,5 @@
---
'@ice/route-manifest': minor
---
refactor: route id generation

View File

@ -0,0 +1,5 @@
---
'@ice/plugin-i18n': major
---
feat: init plugin

View File

@ -0,0 +1,5 @@
---
'@ice/route-manifest': minor
---
feat: support accept one more `defineExtraRoutes` functions

View File

@ -0,0 +1,5 @@
---
'@ice/runtime': patch
---
feat: support handler response

View File

@ -0,0 +1,5 @@
---
'@ice/app': patch
---
fix: routeSpecifier is not unique

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ yalc.lock
# Packages
packages/*/lib/
packages/*/esm/
packages/*/es2017/
# temp folder .ice
examples/*/.ice

View File

@ -0,0 +1,5 @@
import { defineConfig } from '@ice/app';
export default defineConfig(() => ({
ssg: false,
}));

View 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"
}
}

View File

@ -0,0 +1,7 @@
import { defineAppConfig } from 'ice';
export default defineAppConfig(() => ({
app: {
rootId: 'app',
},
}));

View 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;

View File

@ -0,0 +1,10 @@
export const messages: Record<string, any> = {
en: {
changeLanguageTitle: 'Change locale:',
indexTitle: 'Index',
},
'zh-cn': {
changeLanguageTitle: '修改语言:',
indexTitle: '首页',
},
};

View 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>
);
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="@ice/app/types" />

View 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"],
}

View File

@ -16,4 +16,5 @@ export default defineConfig({
locales: ['af'],
}),
],
ssg: false,
});

View File

@ -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"
}
}

View File

@ -0,0 +1,8 @@
export const messages: Record<string, any> = {
en: {
buttonText: 'Button',
},
'zh-cn': {
buttonText: '按钮',
},
};

View File

@ -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>
);
}

View 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>
);
}

View 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,
});

View 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"
}
}

View 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}`);
});

View 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,
}));

View 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;

View File

@ -0,0 +1,8 @@
export const messages: Record<string, any> = {
'en-US': {
buttonText: 'Normal Button',
},
'zh-CN': {
buttonText: '普通按钮',
},
};

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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"]
}

View File

@ -1,3 +0,0 @@
export default function A() {
return <div>111</div>;
}

View File

@ -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 {

View File

@ -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(

View File

@ -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,
};
}

View File

@ -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);

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
};

View File

@ -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) %>,
<% } _%>
},
<% } -%>
};
}

View File

@ -4,5 +4,5 @@ export default ({
requestContext,
renderMode,
}) => ([
<%- routeDefination %>
<%- routeDefinition %>
]);

View 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.

View File

@ -0,0 +1,8 @@
import { defineConfig } from '@ice/pkg';
// https://pkg.ice.work/reference/config-list/
export default defineConfig({
transform: {
formats: ['es2017'],
},
});

View 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
View File

@ -0,0 +1 @@
export * from './es2017/runtime/index';

View File

@ -0,0 +1 @@
export const LOCALE_COOKIE_NAME = 'ice_locale';

View 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;

View 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} />;
};
}

View 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';
}

View 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 };

View File

@ -0,0 +1,7 @@
export function getDefaultLocale() {
return '<%= defaultLocale %>';
}
export function getAllLocales() {
return <%- locales %>;
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="@ice/pkg/types" />

View 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;
}

View 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;
}

View 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);
}
}

View 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));
}
}

View 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,
};
}

View 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;
}

View 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: '/' });
}

View 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
View File

@ -0,0 +1 @@
export * from './es2017/types';

View File

@ -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;

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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",

View File

@ -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": "/",
},
]

View File

@ -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();
});

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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 {

File diff suppressed because it is too large Load Diff

View 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();
});
});

View File

@ -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 中间件一起使用才能生效。[详见](#路由自动重定向)