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: