diff --git a/examples/export-static/.umirc.ts b/examples/export-static/.umirc.ts
new file mode 100644
index 000000000000..813d97a77e44
--- /dev/null
+++ b/examples/export-static/.umirc.ts
@@ -0,0 +1,14 @@
+export default {
+ runtimePublicPath: {},
+ exportStatic: {
+ htmlSuffix: true,
+ dynamicRoot: true,
+ },
+ hash: true,
+ // 配置式路由
+ // routes: [
+ // { path: '/', component: 'index'},
+ // { path: '/page1', component: 'page1/index'},
+ // { path: '/page1/page1_1', component: 'page1/page1_1/index'},
+ // ],
+};
diff --git a/examples/export-static/package.json b/examples/export-static/package.json
new file mode 100644
index 000000000000..69f1f8d07148
--- /dev/null
+++ b/examples/export-static/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@example/export-static",
+ "private": true,
+ "scripts": {
+ "build": "umi build",
+ "dev": "umi dev",
+ "setup": "umi setup",
+ "start": "npm run dev"
+ },
+ "dependencies": {
+ "umi": "workspace:*"
+ }
+}
diff --git a/examples/export-static/src/pages/404.tsx b/examples/export-static/src/pages/404.tsx
new file mode 100644
index 000000000000..e7e1cd7a58e5
--- /dev/null
+++ b/examples/export-static/src/pages/404.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export default function Page1() {
+ return
404
;
+}
diff --git a/examples/export-static/src/pages/bar.css b/examples/export-static/src/pages/bar.css
new file mode 100644
index 000000000000..83de5cdab3e2
--- /dev/null
+++ b/examples/export-static/src/pages/bar.css
@@ -0,0 +1,3 @@
+.bar {
+ background: green;
+}
diff --git a/examples/export-static/src/pages/foo.less b/examples/export-static/src/pages/foo.less
new file mode 100644
index 000000000000..7a241960dc2f
--- /dev/null
+++ b/examples/export-static/src/pages/foo.less
@@ -0,0 +1,10 @@
+
+.foo {
+ color: red;
+}
+.foo2 {
+ font-size: 40px;
+}
+.foo3 {
+ font-weight: bold;
+}
diff --git a/examples/export-static/src/pages/index.tsx b/examples/export-static/src/pages/index.tsx
new file mode 100644
index 000000000000..19d5482876a7
--- /dev/null
+++ b/examples/export-static/src/pages/index.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+// @ts-ignore
+import { Helmet, Link } from 'umi';
+// @ts-ignore
+import fooStyles from './foo.less';
+// @ts-ignore
+import barStyles from './bar.css';
+
+export default function HomePage() {
+ return (
+
+
+ helmet title
+
+
+ Home Page
+
+
+
Page 1(/page1)
+
+
+
Page 1(/page1.html)
+
+
+
Page 1-1(/page1/page1_1)
+
+
+
Page 1-1(/page1/page1_1.html)
+
+
+ );
+}
diff --git a/examples/export-static/src/pages/page1/index.tsx b/examples/export-static/src/pages/page1/index.tsx
new file mode 100644
index 000000000000..8b2708cc87f4
--- /dev/null
+++ b/examples/export-static/src/pages/page1/index.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export default function Page1() {
+ return Page 1
;
+}
diff --git a/examples/export-static/src/pages/page1/page1_1/index.tsx b/examples/export-static/src/pages/page1/page1_1/index.tsx
new file mode 100644
index 000000000000..1ed83874fa74
--- /dev/null
+++ b/examples/export-static/src/pages/page1/page1_1/index.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export default function Page1() {
+ return Page 1-1
;
+}
diff --git a/packages/preset-umi/src/features/exportStatic/exportStatic.ts b/packages/preset-umi/src/features/exportStatic/exportStatic.ts
index ae0d2f9be2b2..0dacf0dfd279 100644
--- a/packages/preset-umi/src/features/exportStatic/exportStatic.ts
+++ b/packages/preset-umi/src/features/exportStatic/exportStatic.ts
@@ -19,26 +19,42 @@ interface IExportHtmlItem {
type IUserExtraRoute = string | { path: string; prerender: boolean };
+function isHtmlRoute(route: IRoute): boolean {
+ return (
+ // skip layout
+ !route.isLayout &&
+ // skip duplicate route
+ !route.noHtml &&
+ // skip dynamic route for win, because `:` is not allowed in file name
+ (!IS_WIN || !route.path.includes('/:')) &&
+ // skip `*` route, because `*` is not working for most site serve services
+ (!route.path.includes('*') ||
+ // skip `404.html`
+ route.absPath === '/*')
+ );
+}
+function getHtmlPath(path: string, htmlSuffix: boolean): string {
+ if (!path) return path;
+ if (path === '/*') return '/404.html';
+ if (path === '/') return '/index.html';
+
+ if (path.endsWith('/')) path = path.slice(0, -1);
+ return htmlSuffix ? `${path}.html` : `${path}/index.html`;
+}
/**
* get export html data from routes
*/
-function getExportHtmlData(routes: Record): IExportHtmlItem[] {
+function getExportHtmlData(
+ routes: Record,
+ htmlSuffix: boolean,
+): IExportHtmlItem[] {
const map = new Map();
- Object.values(routes).forEach((route) => {
+ for (const route of Object.values(routes)) {
const is404 = route.absPath === '/*';
- if (
- // skip layout
- !route.isLayout &&
- // skip dynamic route for win, because `:` is not allowed in file name
- (!IS_WIN || !route.path.includes('/:')) &&
- // skip `*` route, because `*` is not working for most site serve services
- (!route.path.includes('*') ||
- // except `404.html`
- is404)
- ) {
- const file = is404 ? '404.html' : join('.', route.absPath, 'index.html');
+ if (isHtmlRoute(route)) {
+ const file = join('.', getHtmlPath(route.absPath, htmlSuffix));
map.set(file, {
route: {
@@ -49,7 +65,7 @@ function getExportHtmlData(routes: Record): IExportHtmlItem[] {
file,
});
}
- });
+ }
return Array.from(map.values());
}
@@ -112,6 +128,8 @@ export default (api: IApi) => {
schema: ({ zod }) =>
zod
.object({
+ htmlSuffix: zod.boolean(),
+ dynamicRoot: zod.boolean(),
extraRoutePaths: zod.union([
zod.function(),
zod.array(zod.string()),
@@ -129,7 +147,11 @@ export default (api: IApi) => {
// export routes to html files
api.modifyExportHTMLFiles(async (_defaultFiles, opts) => {
- const { publicPath } = api.config;
+ const {
+ publicPath,
+ base,
+ exportStatic: { htmlSuffix, dynamicRoot },
+ } = api.config;
const htmlData = api.appData.exportHtmlData;
const htmlFiles: { path: string; content: string }[] = [];
const { markupArgs: defaultMarkupArgs } = opts;
@@ -137,64 +159,93 @@ export default (api: IApi) => {
for (const { file, route, prerender } of htmlData) {
let markupArgs = defaultMarkupArgs;
- // handle relative publicPath, such as `./`
- if (publicPath.startsWith('.')) {
+ let routerBaseStr = JSON.stringify(base || '/');
+ let publicPathStr = JSON.stringify(publicPath || '/');
+ // handle relative publicPath, such as `./`, same with dynamicRoot
+ if (publicPath.startsWith('.') || dynamicRoot) {
assert(
api.config.runtimePublicPath,
- '`runtimePublicPath` should be enable when `publicPath` is relative!',
+ '`runtimePublicPath` should be enable when `publicPath` is relative or `exportStatic.dynamicRoot` is true!',
);
- const rltPrefix = relative(dirname(file), '.');
+ let pathS = route.path;
+ const isSlash = pathS.endsWith('/');
+ if (pathS === '/404') {
+ //do nothing
+ }
+ // keep the relative path same for route /xxx and /xxx.html
+ else if (htmlSuffix && isSlash) {
+ pathS = pathS.slice(0, -1);
+ }
+ // keep the relative path same for route /xxx/ and /xxx/index.html
+ else if (!htmlSuffix && !isSlash) {
+ pathS = pathS + '/';
+ }
+ const pathN = Math.max(pathS.split('/').length - 1, 1);
+ routerBaseStr = `location.pathname.split('/').slice(0, -${pathN}).concat('').join('/')`;
+ publicPathStr = `location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '') + window.routerBase`;
+
+ const rltPrefix = relative(dirname(file), '.');
+ const joinRltPrefix = (path: string) => {
+ if (!rltPrefix || rltPrefix == '.') {
+ return `.${path.startsWith('/') ? '' : '/'}${path}`;
+ }
+ return winPath(join(rltPrefix, path));
+ };
// prefix for all assets
- if (rltPrefix) {
- // HINT: clone for keep original markupArgs unmodified
- const picked = lodash.cloneDeep(
- lodash.pick(markupArgs, [
- 'favicons',
- 'links',
- 'styles',
- 'headScripts',
- 'scripts',
- ]),
- );
-
- // handle favicons
- picked.favicons.forEach((item: string, i: number) => {
- if (item.startsWith(publicPath)) {
- picked.favicons[i] = winPath(join(rltPrefix, item));
- }
- });
-
- // handle links
- picked.links.forEach((link: { href: string }) => {
- if (link.href?.startsWith(publicPath)) {
- link.href = winPath(join(rltPrefix, link.href));
- }
- });
-
- // handle scripts
- [picked.headScripts, picked.scripts, picked.styles].forEach(
- (group: ({ src: string } | string)[]) => {
- group.forEach((script, i) => {
- if (
- typeof script === 'string' &&
- script.startsWith(publicPath)
- ) {
- group[i] = winPath(join(rltPrefix, script));
- } else if (
- typeof script === 'object' &&
- script.src?.startsWith(publicPath)
- ) {
- script.src = winPath(join(rltPrefix, script.src));
- }
- });
- },
- );
-
- // update markupArgs
- markupArgs = Object.assign({}, markupArgs, picked);
- }
+ // HINT: clone for keep original markupArgs unmodified
+ const picked = lodash.cloneDeep(
+ lodash.pick(markupArgs, [
+ 'favicons',
+ 'links',
+ 'styles',
+ 'headScripts',
+ 'scripts',
+ ]),
+ );
+
+ // handle favicons
+ picked.favicons.forEach((item: string, i: number) => {
+ if (item.startsWith(publicPath)) {
+ picked.favicons[i] = joinRltPrefix(item);
+ }
+ });
+
+ // handle links
+ picked.links.forEach((link: { href: string }) => {
+ if (link.href?.startsWith(publicPath)) {
+ link.href = joinRltPrefix(link.href);
+ }
+ });
+
+ // handle scripts
+ [picked.headScripts, picked.scripts, picked.styles].forEach(
+ (group: ({ src: string } | string)[]) => {
+ group.forEach((script, i) => {
+ if (typeof script === 'string' && script.startsWith(publicPath)) {
+ group[i] = joinRltPrefix(script);
+ } else if (
+ typeof script === 'object' &&
+ script.src?.startsWith(publicPath)
+ ) {
+ script.src = joinRltPrefix(script.src);
+ }
+ });
+ },
+ );
+
+ picked.headScripts.unshift(
+ `window.routerBase = ${routerBaseStr};`,
+ `
+if(!window.publicPath) {
+window.publicPath = ${publicPathStr};
+}
+ `,
+ );
+
+ // update markupArgs
+ markupArgs = Object.assign({}, markupArgs, picked);
}
// append html file
@@ -218,12 +269,13 @@ export default (api: IApi) => {
api.onGenerateFiles(async () => {
const {
- exportStatic: { extraRoutePaths = [] },
+ exportStatic: { extraRoutePaths = [], htmlSuffix },
} = api.config;
const extraHtmlData = getExportHtmlData(
await getRoutesFromUserExtraPaths(extraRoutePaths),
+ htmlSuffix,
);
- const htmlData = getExportHtmlData(api.appData.routes).concat(
+ const htmlData = getExportHtmlData(api.appData.routes, htmlSuffix).concat(
extraHtmlData,
);
@@ -241,6 +293,13 @@ export function modifyClientRenderOpts(memo: any) {
hydrate: hydrate && !{{{ ignorePaths }}}.includes(history.location.pathname),
};
}
+
+export function modifyContextOpts(memo: any) {
+ return {
+ ...memo,
+ basename: window.routerBase || memo.basename,
+ }
+}
`.trim(),
{
ignorePaths: JSON.stringify(
@@ -253,7 +312,24 @@ export function modifyClientRenderOpts(memo: any) {
noPluginDir: true,
});
});
-
+ api.modifyRoutes((routes: Record) => {
+ const {
+ exportStatic: { htmlSuffix },
+ } = api.config;
+ // copy / to /index.html and /xxx to /xxx.html or /xxx/index.html
+ for (let key of Object.keys(routes)) {
+ const route = routes[key];
+ if (isHtmlRoute(route)) {
+ key = `${key}.html`;
+ routes[key] = {
+ ...route,
+ path: getHtmlPath(route.path, htmlSuffix),
+ noHtml: true,
+ };
+ }
+ }
+ return routes;
+ });
api.addRuntimePlugin(() => {
return [`@@/core/exportStaticRuntimePlugin.ts`];
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 89a5b2895eba..b81030f6097c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -658,6 +658,12 @@ importers:
specifier: workspace:*
version: link:../../packages/umi
+ examples/export-static:
+ dependencies:
+ umi:
+ specifier: workspace:*
+ version: link:../../packages/umi
+
examples/legacy:
dependencies:
umi: