From 113bcd600b27d67bbb486e1abd2268444a01bb2f Mon Sep 17 00:00:00 2001 From: wangyiming Date: Thu, 18 Sep 2025 13:14:02 +0800 Subject: [PATCH 1/3] feat: use Rspack to compile document & support data fetch and ssgByEntries for ssg --- packages/cli/plugin-ssg/package.json | 3 +- packages/cli/plugin-ssg/src/index.ts | 79 +- packages/cli/plugin-ssg/src/libs/util.ts | 69 +- packages/cli/plugin-ssg/src/server/index.ts | 223 ++++-- packages/cli/plugin-ssg/src/server/process.ts | 147 ---- packages/cli/plugin-ssg/tests/util.test.ts | 25 +- .../docs/en/configure/app/output/ssg.mdx | 59 +- .../en/configure/app/output/ssgByEntries.mdx | 93 +++ .../en/guides/basic-features/render/ssg.mdx | 42 +- .../docs/zh/configure/app/output/ssg.mdx | 60 +- .../zh/configure/app/output/ssgByEntries.mdx | 94 +++ .../zh/guides/basic-features/render/ssg.mdx | 45 +- packages/runtime/plugin-runtime/package.json | 3 +- .../runtime/plugin-runtime/src/cli/code.ts | 2 +- .../plugin-runtime/src/cli/ssr/index.ts | 20 +- .../plugin-runtime/src/document/Html.tsx | 24 +- .../plugin-runtime/src/document/cli/index.ts | 743 +++++++++++++----- .../shared/builderPlugins/adapterSSR.ts | 9 +- .../app-tools/src/types/config/output.ts | 7 +- packages/toolkit/types/cli/index.d.ts | 18 +- packages/toolkit/utils/src/cli/is/config.ts | 28 +- packages/toolkit/utils/tests/is.test.ts | 4 +- pnpm-lock.yaml | 23 +- .../app-document/modern-rem.config.ts | 5 + .../integration/app-document/modern.config.ts | 5 + .../app-document/src/sub/Document.tsx | 5 +- .../app-document/src/utils/aliasModule.ts | 3 + .../app-document/tests/index.test.ts | 22 +- .../mega-list-routes/modern.config.ts | 3 +- .../fixtures/nested-routes/modern.config.ts | 16 +- .../src/routes/user/[id]/page.data.ts | 15 + .../src/routes/user/[id]/page.jsx | 15 + .../nested-routes/src/routes/user/layout.tsx | 9 + .../ssg/tests/nested-routes.test.ts | 31 + 34 files changed, 1260 insertions(+), 689 deletions(-) delete mode 100644 packages/cli/plugin-ssg/src/server/process.ts create mode 100644 packages/document/main-doc/docs/en/configure/app/output/ssgByEntries.mdx create mode 100644 packages/document/main-doc/docs/zh/configure/app/output/ssgByEntries.mdx create mode 100644 tests/integration/app-document/src/utils/aliasModule.ts create mode 100644 tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.data.ts create mode 100644 tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.jsx create mode 100644 tests/integration/ssg/fixtures/nested-routes/src/routes/user/layout.tsx diff --git a/packages/cli/plugin-ssg/package.json b/packages/cli/plugin-ssg/package.json index a6e06e25bf0e..7b9d139d4db6 100644 --- a/packages/cli/plugin-ssg/package.json +++ b/packages/cli/plugin-ssg/package.json @@ -70,8 +70,7 @@ "@modern-js/utils": "workspace:*", "@swc/helpers": "^0.5.17", "node-mocks-http": "^1.11.0", - "normalize-path": "3.0.0", - "portfinder": "^1.0.38" + "normalize-path": "3.0.0" }, "peerDependencies": { "react-router-dom": ">=7.0.0" diff --git a/packages/cli/plugin-ssg/src/index.ts b/packages/cli/plugin-ssg/src/index.ts index f60a80d10b70..84583532760a 100644 --- a/packages/cli/plugin-ssg/src/index.ts +++ b/packages/cli/plugin-ssg/src/index.ts @@ -1,8 +1,11 @@ import path from 'path'; import type { AppTools, CliPlugin } from '@modern-js/app-tools'; -import type { NestedRouteForCli, PageRoute } from '@modern-js/types'; +import type { + NestedRouteForCli, + PageRoute, + SSGSingleEntryOptions, +} from '@modern-js/types'; import { filterRoutesForServer, logger } from '@modern-js/utils'; -import { generatePath } from 'react-router-dom'; import { makeRoute } from './libs/make'; import { writeHtmlFile } from './libs/output'; import { replaceRoute } from './libs/replace'; @@ -15,7 +18,13 @@ import { writeJSONSpec, } from './libs/util'; import { createServer } from './server'; -import type { AgreedRouteMap, SSGConfig, SsgRoute } from './types'; +import type { + AgreedRoute, + AgreedRouteMap, + SSGConfig, + SSGRouteOptions, + SsgRoute, +} from './types'; export const ssgPlugin = (): CliPlugin => ({ name: '@modern-js/plugin-ssg', @@ -42,11 +51,12 @@ export const ssgPlugin = (): CliPlugin => ({ const { output, server } = resolvedConfig; const { ssg, + ssgByEntries, distPath: { root: outputPath } = {}, } = output; const ssgOptions: SSGConfig = - (Array.isArray(ssg) ? ssg.pop() : ssg) || true; + (Array.isArray(ssg) ? (ssg as any[]).pop() : (ssg as any)) ?? true; const buildDir = path.join(appDirectory, outputPath as string); const routes = readJSONSpec(buildDir); @@ -65,6 +75,7 @@ export const ssgPlugin = (): CliPlugin => ({ entrypoints, pageRoutes, server, + ssgByEntries, ); if (!intermediateOptions) { @@ -76,9 +87,8 @@ export const ssgPlugin = (): CliPlugin => ({ pageRoutes.forEach(pageRoute => { const { entryName, entryPath } = pageRoute; const agreedRoutes = agreedRouteMap[entryName as string]; - let entryOptions = - intermediateOptions[entryName as string] || - intermediateOptions[pageRoute.urlPath]; + let entryOptions = (intermediateOptions[entryName as string] || + intermediateOptions[pageRoute.urlPath]) as SSGSingleEntryOptions; if (!agreedRoutes) { // default behavior for non-agreed route @@ -93,7 +103,7 @@ export const ssgPlugin = (): CliPlugin => ({ // if entryOptions is object and has routes options // add every route in options const { routes: enrtyRoutes, headers } = entryOptions; - enrtyRoutes.forEach(route => { + enrtyRoutes.forEach((route: SSGRouteOptions) => { ssgRoutes.push(makeRoute(pageRoute, route, headers)); }); } @@ -105,42 +115,32 @@ export const ssgPlugin = (): CliPlugin => ({ } if (entryOptions === true) { - entryOptions = { preventDefault: [], routes: [], headers: {} }; + entryOptions = { routes: [], headers: {} } as any; } - const { - preventDefault = [], - routes: userRoutes = [], - headers, - } = entryOptions; + const { routes: userRoutes = [], headers } = + (entryOptions as { + routes?: SSGRouteOptions[]; + headers?: Record; + }) || {}; // if the user sets the routes, then only add them if (userRoutes.length > 0) { - userRoutes.forEach(route => { - if (typeof route === 'string') { - ssgRoutes.push(makeRoute(pageRoute, route, headers)); - } else if (Array.isArray(route.params)) { - route.params.forEach(param => { - ssgRoutes.push( - makeRoute( - pageRoute, - { ...route, url: generatePath(route.url, param) }, - headers, - ), - ); - }); - } else { - ssgRoutes.push(makeRoute(pageRoute, route, headers)); + (userRoutes as SSGRouteOptions[]).forEach( + (route: SSGRouteOptions) => { + if (typeof route === 'string') { + ssgRoutes.push(makeRoute(pageRoute, route, headers)); + } else { + ssgRoutes.push(makeRoute(pageRoute, route, headers)); + } + }, + ); + } else { + // default: add all non-dynamic routes + agreedRoutes.forEach((route: AgreedRoute) => { + if (!isDynamicUrl(route.path!)) { + ssgRoutes.push(makeRoute(pageRoute, route.path!, headers)); } }); - } else { - // otherwith add all except dynamic routes - agreedRoutes - .filter(route => !preventDefault.includes(route.path!)) - .forEach(route => { - if (!isDynamicUrl(route.path!)) { - ssgRoutes.push(makeRoute(pageRoute, route.path!, headers)); - } - }); } } }); @@ -177,12 +177,11 @@ export const ssgPlugin = (): CliPlugin => ({ }); const htmlAry = await createServer( - api, + appContext, ssgRoutes, pageRoutes, apiRoutes, resolvedConfig, - appDirectory, ); // write to dist file diff --git a/packages/cli/plugin-ssg/src/libs/util.ts b/packages/cli/plugin-ssg/src/libs/util.ts index 8a9f43420495..9814745a44fa 100644 --- a/packages/cli/plugin-ssg/src/libs/util.ts +++ b/packages/cli/plugin-ssg/src/libs/util.ts @@ -41,7 +41,7 @@ export function formatPath(str: string) { } export function isDynamicUrl(url: string): boolean { - return url.includes(':'); + return url.includes(':') || url.endsWith('*'); } export function getUrlPrefix(route: SsgRoute, baseUrl: string | string[]) { @@ -112,7 +112,48 @@ export const standardOptions = ( entrypoints: EntryPoint[], routes: ModernRoute[], server: ServerUserConfig, + ssgByEntries?: SSGMultiEntryOptions, ) => { + if (ssgByEntries && Object.keys(ssgByEntries).length > 0) { + const result: SSGMultiEntryOptions = {}; + + Object.keys(ssgByEntries).forEach(key => { + const val = ssgByEntries[key]; + if (typeof val !== 'function') { + result[key] = val; + } + }); + + for (const entry of entrypoints) { + const { entryName } = entry; + const configured = ssgByEntries[entryName]; + if (typeof configured === 'function') { + const routesForEntry = routes.filter(r => r.entryName === entryName); + if (Array.isArray(server?.baseUrl)) { + for (const url of server.baseUrl) { + routesForEntry + .filter( + r => typeof r.urlPath === 'string' && r.urlPath.startsWith(url), + ) + .forEach(r => { + result[r.urlPath as string] = configured(entryName, { + baseUrl: url, + }); + }); + } + } else { + result[entryName] = configured(entryName, { + baseUrl: server?.baseUrl, + }); + } + } else if (typeof configured !== 'undefined') { + result[entryName] = configured; + } + } + + return result; + } + if (ssgOptions === false) { return false; } @@ -125,24 +166,30 @@ export const standardOptions = ( } else if (typeof ssgOptions === 'object') { const isSingle = isSingleEntry(entrypoints); - if (isSingle && typeof (ssgOptions as any).main === 'undefined') { + if (isSingle) { return { main: ssgOptions } as SSGMultiEntryOptions; - } else { - return ssgOptions as SSGMultiEntryOptions; } + + return entrypoints.reduce((opt, entry) => { + opt[entry.entryName] = ssgOptions; + return opt; + }, {} as SSGMultiEntryOptions); } else if (typeof ssgOptions === 'function') { const intermediateOptions: SSGMultiEntryOptions = {}; for (const entrypoint of entrypoints) { const { entryName } = entrypoint; - // TODO: may be async function + const routesForEntry = routes.filter(r => r.entryName === entryName); if (Array.isArray(server?.baseUrl)) { for (const url of server.baseUrl) { - const matchUrl = entryName === 'main' ? url : `${url}/${entryName}`; - const route = routes.find(route => route.urlPath === matchUrl); - intermediateOptions[route?.urlPath as string] = ssgOptions( - entryName, - { baseUrl: url }, - ); + routesForEntry + .filter( + r => typeof r.urlPath === 'string' && r.urlPath.startsWith(url), + ) + .forEach(r => { + intermediateOptions[r.urlPath as string] = ssgOptions(entryName, { + baseUrl: url, + }); + }); } } else { intermediateOptions[entryName] = ssgOptions(entryName, { diff --git a/packages/cli/plugin-ssg/src/server/index.ts b/packages/cli/plugin-ssg/src/server/index.ts index d1ddcbef4ddd..0e22edf246d1 100644 --- a/packages/cli/plugin-ssg/src/server/index.ts +++ b/packages/cli/plugin-ssg/src/server/index.ts @@ -1,92 +1,149 @@ -import childProcess from 'child_process'; +import { IncomingMessage, ServerResponse } from 'node:http'; import path from 'path'; -import type { AppNormalizedConfig, AppToolsAPI } from '@modern-js/app-tools'; -import type { ServerRoute as ModernRoute } from '@modern-js/types'; -import { logger } from '@modern-js/utils'; -import { openRouteSSR } from '../libs/util'; +import type { + AppNormalizedConfig, + AppToolsContext, +} from '@modern-js/app-tools'; +import { + type ProdServerOptions, + createProdServer, + loadServerPlugins, +} from '@modern-js/prod-server'; +import type { + ServerRoute as ModernRoute, + ServerPlugin, +} from '@modern-js/types'; +import { SERVER_DIR, createLogger, getMeta, logger } from '@modern-js/utils'; +import { chunkArray, openRouteSSR } from '../libs/util'; import type { SsgRoute } from '../types'; -import { CLOSE_SIGN } from './consts'; -export const createServer = ( - api: AppToolsAPI, +// SSG only interrupt when stderror, so we need to override the rslog's error to console.error +function getLogger() { + const l = createLogger({ + level: 'verbose', + }); + return { + ...l, + error: (...args: any[]) => { + console.error(...args); + }, + }; +} + +const MAX_CONCURRENT_REQUESTS = 10; + +function createMockIncomingMessage( + url: string, + headers: Record = {}, +): IncomingMessage { + const urlObj = new URL(url); + const mockReq = new IncomingMessage({} as any); + + // Set basic properties that createWebRequest uses + mockReq.url = urlObj.pathname + urlObj.search; + mockReq.method = 'GET'; + mockReq.headers = { + host: urlObj.host, + 'user-agent': 'SSG-Renderer/1.0', + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'accept-language': 'en-US,en;q=0.5', + 'accept-encoding': 'gzip, deflate', + connection: 'keep-alive', + ...headers, + }; + + // Set other required properties for IncomingMessage + mockReq.httpVersion = '1.1'; + mockReq.httpVersionMajor = 1; + mockReq.httpVersionMinor = 1; + mockReq.complete = true; + mockReq.rawHeaders = []; + mockReq.socket = {} as any; + mockReq.connection = mockReq.socket; + + return mockReq; +} + +function createMockServerResponse(): ServerResponse { + const mockRes = new ServerResponse({} as any); + return mockRes; +} + +export const createServer = async ( + appContext: AppToolsContext, ssgRoutes: SsgRoute[], pageRoutes: ModernRoute[], apiRoutes: ModernRoute[], options: AppNormalizedConfig, - appDirectory: string, -): Promise => - new Promise((resolve, reject) => { - // this side of the shallow copy of a route for subsequent render processing, to prevent the modification of the current field - // manually enable the server-side rendering configuration for all routes that require SSG - const entries = ssgRoutes.map(route => route.entryName!); - const backup: ModernRoute[] = openRouteSSR(pageRoutes, entries); - - const total = backup.concat(apiRoutes); - - const cp = childProcess.fork(path.join(__dirname, 'process'), { - cwd: appDirectory, - silent: true, - }); - - const appContext = api.getAppContext(); - // Todo: need use collect server plugins - // maybe build command need add collect, or just call collectServerPlugin hooks - const plugins = appContext.serverPlugins; - - cp.send( - JSON.stringify({ - options, - renderRoutes: ssgRoutes, - routes: total, - appContext: { - // Make sure that bff runs the product of the dist directory, because we dont register ts-node in the child process - apiDirectory: path.join( - appContext.distDirectory, - path.relative(appContext.appDirectory, appContext.apiDirectory), - ), - lambdaDirectory: path.join( - appContext.distDirectory, - path.relative(appContext.appDirectory, appContext.lambdaDirectory), - ), - appDirectory: appContext.appDirectory, - metaName: appContext.metaName, - }, - plugins, - distDirectory: appContext.distDirectory, - }), +): Promise => { + // this side of the shallow copy of a route for subsequent render processing, to prevent the modification of the current field + // manually enable the server-side rendering configuration for all routes that require SSG + const entries = ssgRoutes.map(route => route.entryName!); + const backup: ModernRoute[] = openRouteSSR(pageRoutes, entries); + const total = backup.concat(apiRoutes); + try { + const meta = getMeta(appContext.metaName); + + const distDirectory = appContext.distDirectory; + const serverConfigPath = path.resolve( + distDirectory, + SERVER_DIR, + `${meta}.server`, ); - const htmlChunks: string[] = []; - const htmlAry: string[] = []; - - cp.on('message', (chunk: string) => { - if (chunk !== null) { - htmlChunks.push(chunk); - } else { - const html = htmlChunks.join(''); - htmlAry.push(html); - htmlChunks.length = 0; - } - - if (htmlAry.length === ssgRoutes.length) { - cp.send(CLOSE_SIGN); - resolve(htmlAry); - } - }); - - cp.stderr?.on('data', chunk => { - const str = chunk.toString(); - if (str.includes('Error')) { - logger.error(str); - reject(new Error('ssg render failed')); - cp.kill('SIGKILL'); - } else { - logger.info(str.replace(/[^\S\n]+/g, ' ')); - } - }); - - cp.stdout?.on('data', chunk => { - const str = chunk.toString(); - logger.info(str.replace(/[^\S\n]+/g, ' ')); - }); - }); + const plugins: ServerPlugin[] = appContext.serverPlugins; + + const serverOptions: ProdServerOptions = { + pwd: distDirectory, + config: options as any, + appContext, + serverConfigPath, + routes: total, + plugins: await loadServerPlugins( + plugins, + appContext.appDirectory || distDirectory, + ), + staticGenerate: true, + logger: getLogger(), + }; + + const nodeServer = await createProdServer(serverOptions); + const requestHandler = nodeServer.getRequestHandler(); + + const chunkedRoutes = chunkArray(ssgRoutes, MAX_CONCURRENT_REQUESTS); + const results: string[] = []; + + for (const routes of chunkedRoutes) { + const promises = routes.map(async route => { + const url = `http://localhost${route.urlPath}`; + const request = new Request(url, { + method: 'GET', + headers: { + host: 'localhost', + }, + }); + + const mockReq = createMockIncomingMessage(url); + const mockRes = createMockServerResponse(); + + const response = await requestHandler(request, { + // It is mainly for the enableHandleWeb scenario; the req is useless for other scenarios. + node: { + req: mockReq, + res: mockRes, + }, + }); + + return await response.text(); + }); + + const batch = await Promise.all(promises); + results.push(...batch); + } + + return results; + } catch (e) { + logger.error(e instanceof Error ? e.stack : (e as any).toString()); + throw new Error('ssg render failed'); + } +}; diff --git a/packages/cli/plugin-ssg/src/server/process.ts b/packages/cli/plugin-ssg/src/server/process.ts deleted file mode 100644 index 130476c08488..000000000000 --- a/packages/cli/plugin-ssg/src/server/process.ts +++ /dev/null @@ -1,147 +0,0 @@ -import assert from 'node:assert'; -import { type Server, request } from 'node:http'; -import type { Http2SecureServer } from 'node:http2'; -import path from 'path'; -import type { AppNormalizedConfig } from '@modern-js/app-tools'; -import { - type ProdServerOptions, - createProdServer, - loadServerPlugins, -} from '@modern-js/prod-server'; -import type { - ServerRoute as ModernRoute, - ServerPlugin, -} from '@modern-js/types'; -import { SERVER_DIR, createLogger, getMeta } from '@modern-js/utils'; -import portfinder from 'portfinder'; -import { chunkArray } from '../libs/util'; -import { CLOSE_SIGN } from './consts'; - -// SSG only interrupt when stderror, so we need to override the rslog's error to console.error -function getLogger() { - const logger = createLogger({ - level: 'verbose', - }); - return { - ...logger, - error: (...args: any[]) => { - console.error(...args); - }, - }; -} - -const MAX_CONCURRENT_REQUESTS = 10; - -process.on('message', async (chunk: string) => { - if (chunk === CLOSE_SIGN) { - process.exit(); - } - - const context = JSON.parse(chunk as any); - const { - routes, - renderRoutes, - options, - appContext, - plugins, - distDirectory, - }: { - routes: ModernRoute[]; - renderRoutes: ModernRoute[]; - options: AppNormalizedConfig; - distDirectory: string; - appContext: { - appDirectory?: string; - /** Directory for API modules */ - apiDirectory: string; - /** Directory for lambda modules */ - lambdaDirectory: string; - metaName: string; - }; - plugins: ServerPlugin[]; - } = context; - - let nodeServer: Server | Http2SecureServer | null = null; - try { - const { server: serverConfig } = options; - const meta = getMeta(appContext.metaName); - - const serverConfigPath = path.resolve( - distDirectory, - SERVER_DIR, - `${meta}.server`, - ); - // start server in default port - const defaultPort = Number(process.env.PORT) || serverConfig.port; - portfinder.basePort = defaultPort!; - const port = await portfinder.getPortPromise(); - - const serverOptions: ProdServerOptions = { - pwd: distDirectory, - config: options as any, - appContext, - serverConfigPath, - routes, - plugins: await loadServerPlugins( - plugins, - appContext.appDirectory || distDirectory, - ), - staticGenerate: true, - logger: getLogger(), - }; - - assert(process.send, 'process.send is not available'); - const sendProcessMessage = process.send.bind(process); - nodeServer = await createProdServer(serverOptions); - - nodeServer.listen(port, async () => { - if (!nodeServer) return; - - const chunkedRoutes = chunkArray(renderRoutes, MAX_CONCURRENT_REQUESTS); - - for (const routes of chunkedRoutes) { - const promises = routes.map(async route => - getHtml(`http://localhost:${port}${route.urlPath}`, port), - ); - for (const result of await Promise.all(promises)) { - sendProcessMessage(result); - sendProcessMessage(null); - } - } - nodeServer.close(); - }); - } catch (e) { - nodeServer?.close(); - // throw error will lost the origin error and stack - process.stderr.write(e instanceof Error ? e.stack : (e as any).toString()); - } -}); - -function getHtml(url: string, port: number): Promise { - const headers = { host: `localhost:${port}` }; - - return new Promise((resolve, reject) => { - request( - url, - { - headers, - }, - res => { - const chunks: Uint8Array[] = []; - - res.on('error', error => { - reject(error); - }); - - res.on('data', chunk => { - chunks.push(chunk); - }); - - res.on('end', () => { - const html = Buffer.concat(chunks).toString(); - resolve(html); - }); - }, - ).end(); - }); -} diff --git a/packages/cli/plugin-ssg/tests/util.test.ts b/packages/cli/plugin-ssg/tests/util.test.ts index 0a6f5a69ce67..1b8da716fc03 100644 --- a/packages/cli/plugin-ssg/tests/util.test.ts +++ b/packages/cli/plugin-ssg/tests/util.test.ts @@ -133,12 +133,9 @@ describe('test ssg util function', () => { [], {}, ); - expect(opt5).toEqual(ssg2); + expect(opt5).toEqual({ main: ssg2, home: ssg2 }); - const ssg3 = { - main: { routes: ['/foo', { url: '/baz' }] }, - home: false, - }; + const ssg3 = { routes: ['/foo', { url: '/baz' }] }; const opt6 = standardOptions( ssg3, [ @@ -148,7 +145,10 @@ describe('test ssg util function', () => { [], {}, ); - expect(opt6).toEqual(ssg3); + expect(opt6).toEqual({ + main: { routes: ['/foo', { url: '/baz' }] }, + home: { routes: ['/foo', { url: '/baz' }] }, + }); const ssg4 = () => true; const opt7 = standardOptions( @@ -197,10 +197,12 @@ describe('test ssg util function', () => { { urlPath: '/base1', entryPath: '', + entryName: 'main', }, { urlPath: '/base2', entryPath: '', + entryName: 'main', }, ], { @@ -233,12 +235,13 @@ describe('test ssg util function', () => { { urlPath: '/base1/home', entryPath: '', + entryName: 'home', }, - { urlPath: '/base1/about', entryPath: '' }, - { urlPath: '/base1', entryPath: '' }, - { urlPath: '/base2/home', entryPath: '' }, - { urlPath: '/base2/about', entryPath: '' }, - { urlPath: '/base2', entryPath: '' }, + { urlPath: '/base1/about', entryPath: '', entryName: 'about' }, + { urlPath: '/base1', entryPath: '', entryName: 'main' }, + { urlPath: '/base2/home', entryPath: '', entryName: 'home' }, + { urlPath: '/base2/about', entryPath: '', entryName: 'about' }, + { urlPath: '/base2', entryPath: '', entryName: 'main' }, ], { baseUrl: ['/base1', '/base2'], diff --git a/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx b/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx index e227199c3b29..5fc6d3efdb76 100644 --- a/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx +++ b/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx @@ -9,12 +9,21 @@ title: ssg Configuration to enable the application’s SSG (Static Site Generation) feature. -:::tip Enabling SSG +:::tip This configuration takes effect only when SSG is enabled. Please read the [Static Site Generation](/guides/basic-features/render/ssg) documentation to understand how to enable SSG and its use cases. + ::: -:::info Recommended Reading +:::info The SSG feature is closely related to routing. It is recommended to understand the [routing solution](/guides/basic-features/routes) before using SSG. + +::: + +:::info +- Use `output.ssg` for single-entry apps. +- For multi-entry apps, prefer `output.ssgByEntries`. +- If `output.ssg` is `true` and `output.ssgByEntries` is not set, all routes under all entries are treated as SSG routes. + ::: ## Boolean Type @@ -29,36 +38,6 @@ export default defineConfig({ }); ``` -`output.ssg` can also be configured per entry, with the rules for effective configurations determined by the entry's routing method. - -For example, given the following directory structure, there are conventional routing entry `entryA` and manual routing entry `entryB`: - -```bash -. -└── src - ├── entryA - │ └── routes - └── entryB - └── App.tsx -``` - -You can specify different SSG configurations for `entryA` and `entryB`: - -```js -export default defineConfig({ - output: { - ssg: { - entryA: true, - entryB: false, - }, - }, -}); -``` - -:::info -For more information on the default behavior of **conventional routing** and **manual routing** after enabling SSG, please read [Static Site Generation](/guides/basic-features/render/ssg). -::: - ## Object Type When the value type is `Object`, the following attributes can be configured. @@ -70,12 +49,10 @@ type SSGRouteOptions = | string | { url: string; - params?: Record[]; headers?: Record; }; type SSGOptions = { - preventDefault?: string[]; headers?: Record; routes?: SSGRouteOptions[]; }; @@ -85,9 +62,9 @@ type SSGOptions = { In the example configuration below, SSG will render the pages corresponding to the `/`, `/about`, and `/user/:id` routes. -For the `/user/:id` route, `cookies` will be added to the request headers, and the `id` in `params` will be replaced with `modernjs`. +For the `/user/:id` route, `cookies` will be added to the request headers. -```ts title="modern.config.ts" +```ts title="modern.config.ts (single entry)" export default defineConfig({ output: { ssg: { @@ -95,15 +72,10 @@ export default defineConfig({ '/', '/about', { - url: '/user/:id', + url: '/user/modernjs', headers: { cookies: 'name=modernjs', }, - params: [ - { - id: 'modernjs', - }, - ], }, ], }, @@ -111,6 +83,3 @@ export default defineConfig({ }); ``` -:::note -The configuration method for multiple entries is the same as for a single entry, so it will not be explained further here. -::: diff --git a/packages/document/main-doc/docs/en/configure/app/output/ssgByEntries.mdx b/packages/document/main-doc/docs/en/configure/app/output/ssgByEntries.mdx new file mode 100644 index 000000000000..1c2491cca28d --- /dev/null +++ b/packages/document/main-doc/docs/en/configure/app/output/ssgByEntries.mdx @@ -0,0 +1,93 @@ +--- +title: ssgByEntries +--- + +# output.ssgByEntries + +- **Type:** `Record` +- **Default Value:** `undefined` + +Configure SSG per entry for multi-entry applications. + +:::info +- Use `output.ssg` for single-entry apps. +- For multi-entry apps, prefer `output.ssgByEntries`. +- If `output.ssg` is `true` and `output.ssgByEntries` is not set, all routes under all entries are treated as SSG routes. + +::: + +## Configuration Type + +```ts +type SSGRouteOptions = + | string + | { + url: string; + headers?: Record; + }; + +type SSGOptions = { + headers?: Record; + routes?: SSGRouteOptions[]; +}; + +// ssgByEntries type +type SSGMultiEntryOptions = Record; +``` + +## Examples + +### Enable SSG for some entries + +```ts title="modern.config.ts" +export default defineConfig({ + output: { + ssgByEntries: { + home: true, + admin: false, + }, + }, +}); +``` + +### Configure routes per entry + +```ts title="modern.config.ts" +export default defineConfig({ + output: { + ssgByEntries: { + home: { + routes: ['/', '/about', '/user/modernjs'], + }, + admin: { + routes: ['/', '/dashboard'], + }, + }, + }, +}); +``` + +### Configure headers per entry or route + +```ts title="modern.config.ts" +export default defineConfig({ + output: { + ssgByEntries: { + home: { + headers: { + 'x-tt-env': 'ppe_modernjs', + }, + routes: [ + '/', + { + url: '/about', + headers: { + from: 'modern-website', + }, + }, + ], + }, + }, + }, +}); +``` diff --git a/packages/document/main-doc/docs/en/guides/basic-features/render/ssg.mdx b/packages/document/main-doc/docs/en/guides/basic-features/render/ssg.mdx index 1454e5c400a3..92e053860f94 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/render/ssg.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/render/ssg.mdx @@ -27,6 +27,12 @@ export default defineConfig({ }); ``` +::::info Scope +- Use `output.ssg` for single-entry apps. +- Use `output.ssgByEntries` for multi-entry apps. +- If `output.ssg` is `true` and `output.ssgByEntries` is not set, all routes under all entries are treated as SSG routes. +:::: + ## Development Debugging Since SSG also renders pages in a Node.js environment, we can enable SSR during the **development phase** to expose code issues early and validate the SSG rendering effect: @@ -147,33 +153,43 @@ After running `pnpm run serve` to start the project, inspect the returned docume The above example introduces single-entry scenarios. For more information, refer to the [API Documentation](/configure/app/output/ssg). ::: -## Adding Route Parameters - -In Modern.js, some routes can be dynamic, such as `/user/:id` in manual routing or `/user/[id]/page.tsx` in conventional routing. +## Adding Dynamic Routes - can configure specific parameters in `output.ssg` to render routes with specified parameters. For example: +In manual routing or conventional routing with dynamic segments (e.g., `/user/[id]`), provide concrete paths directly in `routes`. ```js export default defineConfig({ output: { ssg: { routes: [ - { - url: '/user/:id', - params: [{ - id: 'modernjs', - }], - }, + '/', + '/about', + '/user/modernjs', ], }, }, }); ``` -Here, the `/user/modernjs` route will be rendered, and the `id` parameter will be passed to the component during rendering. When multiple values are configured, multiple pages will be generated. +## Multi-entry + +For multi-entry apps, configure per entry via `output.ssgByEntries`: + +```js +export default defineConfig({ + output: { + ssgByEntries: { + home: { + routes: ['/', '/about', '/user/modernjs'], + }, + admin: false, + }, + }, +}); +``` -:::note -The combination of dynamic routing and SSG is very useful for generating static pages in real-time based on data changes from a CMS system. +:::info +See API details: [output.ssgByEntries](/configure/app/output/ssgByEntries) ::: ## Configuring Request Headers for Rendering diff --git a/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx b/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx index 1695b48e3124..474a523772f5 100644 --- a/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx +++ b/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx @@ -9,12 +9,21 @@ title: ssg 开启应用 SSG 功能的配置 -:::tip 开启 SSG 功能 +:::tip 此配置需要在开启 SSG 功能情况下才会生效。请阅读 [静态站点生成](/guides/basic-features/render/ssg) 文档了解如何开启 SSG 功能及使用场景。 + ::: -:::info 前置阅读 +:::info SSG 功能使用与路由关联性较大,建议使用前先了解[路由方案](/guides/basic-features/routes)。 + +::: + +:::info +- 单入口建议使用 `output.ssg`。 +- 多入口建议优先使用 `output.ssgByEntries`。 +- 当 `output.ssg` 为 `true` 且未配置 `output.ssgByEntries` 时,所有入口下的所有路由都会作为 SSG 路由处理。 + ::: ## Boolean 类型 @@ -29,36 +38,6 @@ export default defineConfig({ }); ``` -`output.ssg` 也可以按照入口配置,配置生效的规则同样由入口路由方式决定。 - -例如以下目录结构,分别存在约定式路由入口 `entryA` 和自控式路由入口 `entryB`: - -```bash -. -└── src - ├── entryA - │ └── routes - └── entryB - └── App.tsx -``` - -你可以指定 `entryA` 和 `entryB` 的不同 SSG 配置: - -```js -export default defineConfig({ - output: { - ssg: { - entryA: true, - entryB: false, - }, - }, -}); -``` - -:::info -更多关于**约定式路由**和**自控式路由**在开启 SSG 后的默认行为,请阅读 [静态站点生成](/guides/basic-features/render/ssg) 。 -::: - ## Object 类型 当值类型为 `Object` 时,可以配置如下属性。 @@ -70,12 +49,10 @@ type SSGRouteOptions = | string | { url: string; - params?: Record[]; headers?: Record; }; type SSGOptions = { - preventDefault?: string[]; headers?: Record; routes?: SSGRouteOptions[]; }; @@ -85,9 +62,9 @@ type SSGOptions = { 下面的示例配置中,SSG 会渲染 `/`、`/about` 和 `/user/:id` 三条路由对应的页面。 -对于 `/user/:id` 路由,`cookies` 添加到请求头中,同时会将 `params` 中的 `id` 替换为 `modernjs`。 +对于 `/user/:id` 路由,会在请求头中添加 `cookies`,并指定具体的路径。 -```ts title="modern.config.ts" +```ts title="modern.config.ts(单入口)" export default defineConfig({ output: { ssg: { @@ -95,22 +72,13 @@ export default defineConfig({ '/', '/about', { - url: '/user/:id', + url: '/user/modernjs', headers: { cookies: 'name=modernjs', }, - params: [ - { - id: 'modernjs', - }, - ], }, ], }, }, }); ``` - -:::note -多入口的情况和单入口的配置方式一致,这里不再额外介绍。 -::: diff --git a/packages/document/main-doc/docs/zh/configure/app/output/ssgByEntries.mdx b/packages/document/main-doc/docs/zh/configure/app/output/ssgByEntries.mdx new file mode 100644 index 000000000000..0d664116c204 --- /dev/null +++ b/packages/document/main-doc/docs/zh/configure/app/output/ssgByEntries.mdx @@ -0,0 +1,94 @@ +--- +title: ssgByEntries +--- + +# output.ssgByEntries + +- **类型:** `Record` +- **默认值:** `undefined` + +在多入口应用中,为每个入口分别配置 SSG。 + +:::info +- 单入口建议使用 `output.ssg`。 +- 多入口建议优先使用 `output.ssgByEntries`。 +- 当 `output.ssg` 为 `true` 且未配置 `output.ssgByEntries` 时,所有入口下的所有路由都会作为 SSG 路由处理。 + +::: + + +## 配置类型 + +```ts +type SSGRouteOptions = + | string + | { + url: string; + headers?: Record; + }; + +type SSGOptions = { + headers?: Record; + routes?: SSGRouteOptions[]; +}; + +// ssgByEntries 类型 +type SSGMultiEntryOptions = Record; +``` + +## 示例 + +### 按入口启用/禁用 SSG + +```ts title="modern.config.ts" +export default defineConfig({ + output: { + ssgByEntries: { + home: true, + admin: false, + }, + }, +}); +``` + +### 按入口配置具体路由 + +```ts title="modern.config.ts" +export default defineConfig({ + output: { + ssgByEntries: { + home: { + routes: ['/', '/about', '/user/modernjs'], + }, + admin: { + routes: ['/', '/dashboard'], + }, + }, + }, +}); +``` + +### 配置入口或路由请求头 + +```ts title="modern.config.ts" +export default defineConfig({ + output: { + ssgByEntries: { + home: { + headers: { + 'x-tt-env': 'ppe_modernjs', + }, + routes: [ + '/', + { + url: '/about', + headers: { + from: 'modern-website', + }, + }, + ], + }, + }, + }, +}); +``` diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/render/ssg.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/render/ssg.mdx index bb05df55abec..665fc41105bd 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/render/ssg.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/render/ssg.mdx @@ -28,6 +28,12 @@ export default defineConfig({ }); ``` +::::info 适用范围 +- 单入口请使用 `output.ssg`。 +- 多入口请使用 `output.ssgByEntries`。 +- 当仅设置 `output.ssg: true` 且未配置 `output.ssgByEntries` 时,所有入口下的所有路由都会作为 SSG 路由处理。 +:::: + ## 开发环境调试 SSG 也是在 Node.js 环境渲染页面,因此我们可以在**开发阶段开启 SSR**,提前暴露代码问题,验证 SSG 渲染效果: @@ -148,35 +154,44 @@ export default defineConfig({ 以上仅介绍了单入口的情况,更多相关内容可以查看 [API 文档](/configure/app/output/ssg)。 ::: -## 添加路由参数 - -在 Modern.js 中,部分路由可能是动态的,例如自控式路由中的 `/user/:id` 或是约定式路由中 `user/[id]/page.tsx` 文件生成的路由。 +## 添加动态路由 -可以在 `output.ssg` 中配置具体的参数,渲染指定参数的路由,例如: +在自控式路由或包含动态段的约定式路由(如 `/user/[id]`)中,直接在 `routes` 中写入具体路径。 ```js export default defineConfig({ output: { ssg: { routes: [ - { - url: '/user/:id', - params: [{ - id: 'modernjs', - }], - }, + '/', + '/about', + '/user/modernjs', ], }, }, }); ``` -此时,`/user/modernjs` 路由会被渲染,并且在渲染时,会将 `id` 参数传递给组件。当配置多个值时,对应会生成多张页面。 +## 多入口 -:::note -动态路由和 SSG 的组合,在根据 CMS 系统数据变更,实时生成静态页面时非常有用。 -::: +多入口应用请通过 `output.ssgByEntries` 按入口分别配置: + +```js +export default defineConfig({ + output: { + ssgByEntries: { + home: { + routes: ['/', '/about', '/user/modernjs'], + }, + admin: false, + }, + }, +}); +``` +:::info +详细配置请参考:[output.ssgByEntries](/configure/app/output/ssgByEntries) +::: ## 配置渲染请求头 @@ -207,4 +222,4 @@ export default defineConfig({ :::tip 路由中设置的 `headers` 会覆盖入口中设置的 `headers`。 -::: \ No newline at end of file +::: diff --git a/packages/runtime/plugin-runtime/package.json b/packages/runtime/plugin-runtime/package.json index c21de5312a10..15bf7bfb629f 100644 --- a/packages/runtime/plugin-runtime/package.json +++ b/packages/runtime/plugin-runtime/package.json @@ -236,7 +236,8 @@ "isbot": "3.7.1", "react-helmet": "^6.1.0", "react-is": "^18.3.1", - "react-side-effect": "^2.1.2" + "react-side-effect": "^2.1.2", + "entities": "^7.0.0" }, "peerDependencies": { "react": ">=17", diff --git a/packages/runtime/plugin-runtime/src/cli/code.ts b/packages/runtime/plugin-runtime/src/cli/code.ts index 5ed50bc416f2..50321af82ab0 100644 --- a/packages/runtime/plugin-runtime/src/cli/code.ts +++ b/packages/runtime/plugin-runtime/src/cli/code.ts @@ -26,7 +26,7 @@ function getSSRMode( ): 'string' | 'stream' | false { const { ssr, ssrByEntries } = config.server; - if (config.output.ssg) { + if (config.output.ssg || config.output.ssgByEntries) { return 'string'; } diff --git a/packages/runtime/plugin-runtime/src/cli/ssr/index.ts b/packages/runtime/plugin-runtime/src/cli/ssr/index.ts index f513a89bc0a2..1821537e8cfd 100644 --- a/packages/runtime/plugin-runtime/src/cli/ssr/index.ts +++ b/packages/runtime/plugin-runtime/src/cli/ssr/index.ts @@ -16,7 +16,15 @@ const hasStringSSREntry = (userConfig: AppToolsNormalizedConfig): boolean => { const { server, output } = userConfig; // ssg need use stringSSR. - if ((server?.ssr || output.ssg) && !isStreaming(server.ssr)) { + if (output?.ssg) { + return true; + } + + if (output?.ssgByEntries && Object.keys(output.ssgByEntries).length > 0) { + return true; + } + + if (server?.ssr && !isStreaming(server.ssr)) { return true; } @@ -37,9 +45,13 @@ const hasStringSSREntry = (userConfig: AppToolsNormalizedConfig): boolean => { const checkUseStringSSR = (config: AppToolsNormalizedConfig): boolean => { const { output } = config; - // ssg is not support streaming ssr. - // so we assumes use String SSR when using ssg. - return Boolean(output?.ssg) || hasStringSSREntry(config); + if (output?.ssg) { + return true; + } + if (output?.ssgByEntries && Object.keys(output.ssgByEntries).length > 0) { + return true; + } + return hasStringSSREntry(config); }; const ssrBuilderPlugin = ( diff --git a/packages/runtime/plugin-runtime/src/document/Html.tsx b/packages/runtime/plugin-runtime/src/document/Html.tsx index def1f6f53a08..b93d4ad4b8a4 100644 --- a/packages/runtime/plugin-runtime/src/document/Html.tsx +++ b/packages/runtime/plugin-runtime/src/document/Html.tsx @@ -5,12 +5,22 @@ import { DocumentStructureContext } from './DocumentStructureContext'; import { Head } from './Head'; /** - * get the directly son element + * get the directly son element by name */ -function findTargetChild(tag: string, children: ReactElement[]) { +function findTargetChildByName(tag: string, children: ReactElement[]) { return children.find(item => getEleType(item) === tag); } +/** + * get the directly son element by component reference + */ +function findTargetChildByComponent( + component: unknown, + children: ReactElement[], +) { + return children.find(item => item?.type === component); +} + /** * get the type of react element */ @@ -50,10 +60,16 @@ export function Html( const { children, ...rest } = props; // deal with the component with default - const hasSetHead = Boolean(findTargetChild('Head', children)); + const hasSetHead = Boolean( + findTargetChildByComponent(Head, children) || + findTargetChildByName('Head', children), + ); const hasSetScripts = Boolean(findTargetElement('Scripts', children)); const hasSetLinks = Boolean(findTargetElement('Links', children)); - const hasSetBody = Boolean(findTargetChild('Body', children)); + const hasSetBody = Boolean( + findTargetChildByComponent(Body, children) || + findTargetChildByName('Body', children), + ); const hasSetRoot = Boolean(findTargetElement('Root', children)); const hasSetTitle = Boolean(findTargetElement('title', children)); const notMissMustChild = [ diff --git a/packages/runtime/plugin-runtime/src/document/cli/index.ts b/packages/runtime/plugin-runtime/src/document/cli/index.ts index 3e38ce2fa592..051a81b2de76 100644 --- a/packages/runtime/plugin-runtime/src/document/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/document/cli/index.ts @@ -1,3 +1,5 @@ +import Module from 'module'; +import { builtinModules } from 'module'; import path from 'path'; import type { AppTools, @@ -6,11 +8,8 @@ import type { } from '@modern-js/app-tools'; import type { Entrypoint } from '@modern-js/types/cli'; import { fs, createDebugger, findExists } from '@modern-js/utils'; -import { build } from 'esbuild'; -import React from 'react'; -import ReactDomServer from 'react-dom/server'; - -import { DocumentContext } from '../DocumentContext'; +import type { Rspack, RspackChain } from '@rsbuild/core'; +import { decodeHTML } from 'entities'; import { BODY_PARTICALS_SEPARATOR, @@ -37,11 +36,158 @@ import { TOP_PARTICALS_SEPARATOR, } from '../constants'; -const debug = createDebugger('html_generate'); +interface DocumentParams { + processEnv: NodeJS.ProcessEnv; + config: { + output: NormalizedConfig['output']; + }; + entryName: string; + templateParams: Record; +} + +interface PartialsContent { + partialsTop: string; + partialsHead: string; + partialsBody: string; +} + +interface HtmlWebpackPluginTags { + headTags: Array<{ tagName: string; toString(): string }>; + bodyTags: { toString(): string }; +} + +interface HtmlWebpackPlugin { + tags: HtmlWebpackPluginTags; +} + +interface ExternalRequest { + request?: string; +} + +type Compiler = Rspack.Compiler; +type Compilation = Rspack.Compilation; + +const debug = createDebugger('document'); + +const entryName2DocCode = new Map(); + +const CONSTANTS = { + GLOBAL_DOC_RENDERERS: '__MODERN_DOC_RENDERERS__', + DOCUMENT_OUTPUT_DIR: 'document', + TEMP_ENTRY_PREFIX: '_entry_', + HTML_OUTPUT_PREFIX: '_', + HTML_OUTPUT_SUFFIX: '.html.js', + CHILD_COMPILER_PREFIX: 'modernjs-document-', + COMMONJS_EXTERNAL_PREFIX: 'commonjs ', + NODE_PREFIX: 'node:', +} as const; + +const EXTERNAL_MAPPINGS = { + react: 'react', + 'react/jsx-runtime': 'react/jsx-runtime', + 'react/jsx-dev-runtime': 'react/jsx-dev-runtime', + 'react-dom/server': 'react-dom/server', +} as const; + +// global stores helpers +// Because Bundler will build IFEE, the components and renderers need to be stored on global. +const getGlobalDocRenderers = () => { + const g = globalThis as any; + g[CONSTANTS.GLOBAL_DOC_RENDERERS] = g[CONSTANTS.GLOBAL_DOC_RENDERERS] || {}; + return g[CONSTANTS.GLOBAL_DOC_RENDERERS] as Record< + string, + (p: DocumentParams) => string + >; +}; + +// clear cached renderer to support HMR for Document.tsx +const clearGlobalDocRenderer = (entryName: string): void => { + const renderers = getGlobalDocRenderers(); + if (renderers[entryName]) { + delete renderers[entryName]; + } +}; + +const decodeHtmlEntities = (input: string): string => decodeHTML(input); + +const isWindowsAbs = (req: string): boolean => /^[a-zA-Z]:[\\/]/.test(req); +const isRelativeOrAbs = (req: string): boolean => + req.startsWith('.') || req.startsWith('/') || isWindowsAbs(req); +const isAsset = (req: string): boolean => + /\.(css|less|scss|sass|styl|png|jpe?g|gif|svg|ico|woff2?|ttf|eot)(?:[?#].*)?$/.test( + req, + ); + +const processScriptPlaceholders = (html: string, nonce?: string): string => { + if ( + !html.includes(DOCUMENT_SCRIPT_PLACEHOLDER_START) || + !html.includes(DOCUMENT_SCRIPT_PLACEHOLDER_END) + ) { + return html; + } -// get the entry document file, -// if not exist, fallback to src/ -export const getDocumenByEntryName = function ( + const nonceAttr = nonce ? `nonce="${nonce}"` : ''; + return html.replace( + new RegExp( + `${DOCUMENT_SCRIPT_PLACEHOLDER_START}${DOCUMENT_SCRIPT_ATTRIBUTES_START}([\\s\\S]*?)${DOCUMENT_SCRIPT_ATTRIBUTES_END}([\\s\\S]*?)${DOCUMENT_SCRIPT_PLACEHOLDER_END}`, + 'g', + ), + (_scriptStr: string, $1: string, $2: string) => + ``, + ); +}; + +const processStylePlaceholders = (html: string): string => { + if ( + !html.includes(DOCUMENT_STYLE_PLACEHOLDER_START) || + !html.includes(DOCUMENT_STYLE_PLACEHOLDER_END) + ) { + return html; + } + + return html.replace( + new RegExp( + `${DOCUMENT_STYLE_PLACEHOLDER_START}([\\s\\S]*?)${DOCUMENT_STYLE_PLACEHOLDER_END}`, + 'g', + ), + (_styleStr: string, $1: string) => + ``, + ); +}; + +const processCommentPlaceholders = (html: string): string => { + if ( + !html.includes(DOCUMENT_COMMENT_PLACEHOLDER_START) || + !html.includes(DOCUMENT_COMMENT_PLACEHOLDER_END) + ) { + return html; + } + + return html.replace( + new RegExp( + `${DOCUMENT_COMMENT_PLACEHOLDER_START}([\\s\\S]*?)${DOCUMENT_COMMENT_PLACEHOLDER_END}`, + 'g', + ), + (_scriptStr: string, $1: string) => + `${decodeHtmlEntities(decodeURIComponent($1))}`, + ); +}; + +// load CommonJS module from code string (evaluated in Node), returns exports +const requireFromString = (code: string, filename: string) => { + const m = new Module.Module(filename, module.parent as Module); + m.filename = filename; + // set proper resolution paths for nested requires + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore private API used intentionally + m.paths = Module.Module._nodeModulePaths(path.dirname(filename)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore private API _compile + m._compile(code, filename); + return m.exports; +}; + +export const getDocumentByEntryName = function ( entrypoints: Entrypoint[], entryName: string, fallbackDir?: string, @@ -60,23 +206,248 @@ export const getDocumenByEntryName = function ( : []; const docFile = findExists([...entryDirs, ...fallbackDirs]); - return docFile || undefined; }; +// external node buildin modules +const createExternalHandler = + () => + ({ request }: ExternalRequest) => { + const req = request || ''; + if (isAsset(req)) return; + if (req.startsWith(CONSTANTS.NODE_PREFIX)) { + return `${CONSTANTS.COMMONJS_EXTERNAL_PREFIX}${req}`; + } + if (builtinModules.includes(req)) { + return `${CONSTANTS.COMMONJS_EXTERNAL_PREFIX}${req}`; + } + if (isRelativeOrAbs(req)) return; + return; + }; + +const configureChildCompiler = ( + child: Compiler, + compiler: Compiler, + appDirectory: string, +) => { + child.options.mode = compiler.options.mode; + child.options.target = 'node'; + child.options.context = compiler.options.context || appDirectory; + child.options.resolve = { + ...child.options.resolve, + fallback: {}, + }; + child.options.module = compiler.options.module; + child.options.externalsPresets = { node: true }; + child.options.devtool = false; +}; + +const applyExternalsPlugin = (child: Compiler, compiler: Compiler) => { + const ExternalsPlugin = compiler.rspack?.ExternalsPlugin; + if (ExternalsPlugin) { + new ExternalsPlugin('commonjs', [ + createExternalHandler(), + EXTERNAL_MAPPINGS, + ]).apply(child); + } +}; + +const generateEntryCode = (docPath: string, _entryName: string): string => { + return `var React = require('react'); +var ReactDomServer = require('react-dom/server'); +var exp = require(${JSON.stringify(docPath)}); +var DocumentContext = require('@modern-js/runtime/document').DocumentContext; + +var Document = exp && exp.default; + +// expose to global for host to consume +var g = (typeof globalThis !== 'undefined' ? globalThis : global); +g.__MODERN_DOC_RENDERERS__ = g.__MODERN_DOC_RENDERERS__ || {}; + +function render(documentParams) { + var HTMLElement = React.createElement( + DocumentContext.Provider, + { value: documentParams }, + React.createElement(Document, null) + ); + return ReactDomServer.renderToStaticMarkup(HTMLElement); +} + +g.__MODERN_DOC_RENDERERS__[${JSON.stringify(_entryName)}] = render; + +module.exports = { render: render };`; +}; + +const processChildCompilation = async ( + entryName: string, + docPath: string, + compilation: Compilation, + compiler: Compiler, + appDirectory: string, + internalDirectory: string, +): Promise => { + return new Promise((resolve, reject) => { + const outFile = path.posix.join( + CONSTANTS.DOCUMENT_OUTPUT_DIR, + `${CONSTANTS.HTML_OUTPUT_PREFIX}${entryName}${CONSTANTS.HTML_OUTPUT_SUFFIX}`, + ); + + const child = compilation.createChildCompiler( + `${CONSTANTS.CHILD_COMPILER_PREFIX}${entryName}`, + { + filename: outFile, + library: { type: 'commonjs2' }, + }, + [], + ); + + configureChildCompiler(child, compiler, appDirectory); + // external react related dependencies + applyExternalsPlugin(child, compiler); + + const entryDir = path.join( + internalDirectory, + CONSTANTS.DOCUMENT_OUTPUT_DIR, + ); + const tempEntry = path.join( + entryDir, + `${CONSTANTS.TEMP_ENTRY_PREFIX}${entryName}.js`, + ); + + const finalize = () => { + try { + const EntryPlugin = compiler.rspack?.EntryPlugin; + if (!EntryPlugin) { + throw new Error('EntryPlugin not available'); + } + + new EntryPlugin(compiler.context, tempEntry, { + name: `${CONSTANTS.CHILD_COMPILER_PREFIX}${entryName}`, + }).apply(child); + + child.runAsChild( + ( + err?: Error | null, + _entries?: any, + childCompilation?: Compilation, + ) => { + if (err) return reject(err); + try { + if (!childCompilation) { + throw new Error('Child compilation is undefined'); + } + const asset = + childCompilation.assets[outFile] || + childCompilation.getAsset?.(outFile)?.source; + const code: string = + typeof asset?.source === 'function' + ? asset.source().toString() + : typeof asset === 'string' + ? asset + : asset?.buffer?.().toString?.() || ''; + + if (!code) { + debug('Document child compiler: empty asset for %s', entryName); + } else { + entryName2DocCode.set(entryName, code); + debug( + 'Document child compiler: cached injected bundle for %s', + entryName, + ); + } + resolve(); + } catch (e) { + reject(e as Error); + } + }, + ); + } catch (e) { + reject(e as Error); + } + }; + + try { + fs.ensureFile(tempEntry) + .then(() => { + const entryCode = generateEntryCode(docPath, entryName); + return fs.writeFile(tempEntry, entryCode); + }) + .then(finalize) + .catch((e: unknown) => reject(e as Error)); + } catch (e) { + reject(e as Error); + } + }); +}; + export const documentPlugin = (): CliPlugin => ({ name: '@modern-js/plugin-document', pre: ['@modern-js/plugin-analyze'], setup: async api => { - // get params for document.tsx - function getDocParams(params: { + class ModernJsDocumentChildCompilerPlugin { + name = 'ModernJsDocumentChildCompilerPlugin'; + + apply(compiler: Compiler) { + compiler.hooks.make.tapPromise( + this.name, + async (compilation: Compilation) => { + try { + const { entrypoints, appDirectory, internalDirectory } = + api.getAppContext(); + const tasks: Promise[] = []; + + for (const ep of entrypoints || []) { + const entryName = ep.entryName; + const docPath = getDocumentByEntryName( + entrypoints!, + entryName, + appDirectory, + ); + if (!docPath) continue; + + // ensure host compiler watches Document file for HMR + compilation.fileDependencies?.add?.(docPath); + + // clear cached renderer so updated Document takes effect + clearGlobalDocRenderer(entryName); + + tasks.push( + processChildCompilation( + entryName, + docPath, + compilation, + compiler, + appDirectory, + internalDirectory, + ), + ); + } + await Promise.all(tasks); + } catch (e) { + debug('Document child compiler make hook failed: %o', e); + } + }, + ); + } + } + + api.config(() => ({ + tools: { + bundlerChain: (chain: RspackChain) => { + chain + .plugin('modernjs-document-child-compiler') + .use(ModernJsDocumentChildCompilerPlugin, []); + }, + }, + })); + + const getDocParams = (params: { config: NormalizedConfig; entryName: string; templateParameters: Record; - }) { + }): DocumentParams => { const { config, templateParameters, entryName } = params; - // for enough params, devide as:process, config, templateParams return { processEnv: process.env, config: { @@ -85,222 +456,177 @@ export const documentPlugin = (): CliPlugin => ({ entryName, templateParams: templateParameters, }; - } + }; + + const loadRender = async ( + entryName: string, + internalDirectory: string, + ): Promise<{ renderer?: (p: DocumentParams) => string }> => { + const renderers = getGlobalDocRenderers(); + const globalRenderer = renderers[entryName]; + if (globalRenderer) { + return { renderer: globalRenderer }; + } + + const cached = entryName2DocCode.get(entryName); + if (!cached) { + throw new Error( + `Document bundle for entry "${entryName}" not found. The document will not override html templateContent.`, + ); + } + + const filename = path.join( + internalDirectory, + `./${CONSTANTS.DOCUMENT_OUTPUT_DIR}/${CONSTANTS.HTML_OUTPUT_PREFIX}${entryName}${CONSTANTS.HTML_OUTPUT_SUFFIX}`, + ); + + requireFromString(cached, filename); + + return { renderer: renderers[entryName] }; + }; + + const processPartials = ( + html: string, + entryName: string, + partialsByEntrypoint: Record< + string, + { top: string[]; head: string[]; body: string[] } + >, + ): string => { + const partialsContent: PartialsContent = { + partialsTop: '', + partialsHead: '', + partialsBody: '', + }; + + if (partialsByEntrypoint?.[entryName]) { + partialsContent.partialsTop = + partialsByEntrypoint[entryName].top.join('\n'); + partialsContent.partialsHead = + partialsByEntrypoint[entryName].head.join('\n'); + partialsContent.partialsBody = + partialsByEntrypoint[entryName].body.join('\n'); + } + + return html + .replace(TOP_PARTICALS_SEPARATOR, () => partialsContent.partialsTop) + .replace(HEAD_PARTICALS_SEPARATOR, () => partialsContent.partialsHead) + .replace(BODY_PARTICALS_SEPARATOR, () => partialsContent.partialsBody); + }; + + const extractHtmlTags = ( + htmlWebpackPlugin: HtmlWebpackPlugin, + templateParameters: Record, + ) => { + const scripts = [ + htmlWebpackPlugin.tags.headTags + .filter(item => item.tagName === 'script') + .join(''), + htmlWebpackPlugin.tags.bodyTags.toString(), + ].join(''); + + const links = htmlWebpackPlugin.tags.headTags + .filter(item => item.tagName === 'link') + .join(''); + + const metas = [ + templateParameters.meta, + htmlWebpackPlugin.tags.headTags + .filter( + item => + item.tagName !== 'script' && + item.tagName !== 'link' && + item.tagName !== 'title', + ) + .join(''), + ].join(''); + + const titles: string = htmlWebpackPlugin.tags.headTags + .filter(item => item.tagName === 'title') + .join('') + .replace('', '') + .replace('', ''); + + return { scripts, links, metas, titles }; + }; + + const processPlaceholders = ( + html: string, + config: NormalizedConfig, + scripts: string, + links: string, + metas: string, + titles: string, + ): string => { + const { nonce } = config.security || {}; + + let processedHtml = processScriptPlaceholders(html, nonce); + processedHtml = processStylePlaceholders(processedHtml); + processedHtml = processCommentPlaceholders(processedHtml); + + return `${processedHtml}` + .replace(DOCUMENT_META_PLACEHOLDER, () => metas) + .replace(DOCUMENT_SSR_PLACEHOLDER, () => HTML_SEPARATOR) + .replace(DOCUMENT_SCRIPTS_PLACEHOLDER, () => scripts) + .replace(DOCUMENT_LINKS_PLACEHOLDER, () => links) + .replace( + DOCUMENT_CHUNKSMAP_PLACEHOLDER, + () => PLACEHOLDER_REPLACER_MAP[DOCUMENT_CHUNKSMAP_PLACEHOLDER], + ) + .replace( + DOCUMENT_SSRDATASCRIPT_PLACEHOLDER, + () => PLACEHOLDER_REPLACER_MAP[DOCUMENT_SSRDATASCRIPT_PLACEHOLDER], + ) + .replace(DOCUMENT_TITLE_PLACEHOLDER, () => titles); + }; + const documentEntry = ( entryName: string, - // config: HtmlPluginConfig, templateParameters: Record, ) => { const { entrypoints, internalDirectory, appDirectory } = api.getAppContext(); - // search the document.[tsx|jsx|js|ts] under entry - const documentFilePath = getDocumenByEntryName( + + const documentFilePath = getDocumentByEntryName( entrypoints, entryName, appDirectory, ); - // if no document file, do nothing as default + if (!documentFilePath) { return null; } - - return async ({ htmlWebpackPlugin }: { [option: string]: any }) => { + // Don't know why we can't use htmlRspackPlugin, it can't get the tags. + return async ({ + htmlWebpackPlugin, + }: { [option: string]: HtmlWebpackPlugin }) => { const config = api.getNormalizedConfig(); - const documentParams = getDocParams({ config: config as NormalizedConfig, entryName, templateParameters, }); - // set a temporary tsconfig file for divide the influence by project's jsx - const tempTsConfigFile = path.join( - internalDirectory, - `./document/_tempTsconfig.json`, - ); - const userTsConfigFilePath = path.join(appDirectory, 'tsconfig.json'); - let tsConfig; - try { - tsConfig = await require(userTsConfigFilePath); - } catch (err) { - tsConfig = {}; - } - if (tsConfig?.compilerOptions) { - tsConfig.compilerOptions.jsx = 'react-jsx'; - } else { - tsConfig.compilerOptions = { - jsx: 'react-jsx', - }; - } - fs.outputFileSync(tempTsConfigFile, JSON.stringify(tsConfig)); - - const htmlOutputFile = path.join( - internalDirectory, - `./document/_${entryName}.html.js`, - ); - // transform document file to html string - await build({ - entryPoints: [documentFilePath], - outfile: htmlOutputFile, - platform: 'node', - // change esbuild use the rootDir tsconfig.json as default to tempTsConfigFile - tsconfig: tempTsConfigFile, - target: 'es6', - loader: { - '.ts': 'ts', - '.tsx': 'tsx', - }, - bundle: true, - plugins: [ - { - name: 'make-all-packages-external', - setup(build) { - // https://github.com/evanw/esbuild/issues/619#issuecomment-751995294 - build.onResolve( - { filter: /^[^./]|^\.[^./]|^\.\.[^/]/ }, - args => { - let external = true; - // FIXME: windows external entrypoint - if (args.kind === 'entry-point') { - external = false; - } - return { - path: args.path, - external, - }; - }, - ); - }, - }, - ], - }); - - delete require.cache[require.resolve(htmlOutputFile)]; - const Document = (await require(htmlOutputFile)).default; - const HTMLElement = React.createElement( - DocumentContext.Provider, - { value: documentParams }, - React.createElement(Document, null), - ); - let html = ReactDomServer.renderToStaticMarkup(HTMLElement); + const { renderer } = await loadRender(entryName, internalDirectory); + let html = renderer?.(documentParams) as string; debug("entry %s's document jsx rendered html: %o", entryName, html); - // htmlWebpackPlugin.tags - const { partialsByEntrypoint } = api.getAppContext(); - const scripts = [ - htmlWebpackPlugin.tags.headTags - .filter((item: any) => item.tagName === 'script') - .join(''), - htmlWebpackPlugin.tags.bodyTags.toString(), - ].join(''); - // support partials html - const partialsContent = { - partialsTop: '', - partialsHead: '', - partialsBody: '', - }; - if (partialsByEntrypoint?.[entryName]) { - partialsContent.partialsTop = - partialsByEntrypoint[entryName].top.join('\n'); - partialsContent.partialsHead = - partialsByEntrypoint[entryName].head.join('\n'); - partialsContent.partialsBody = - partialsByEntrypoint[entryName].body.join('\n'); - } - html = html - .replace(TOP_PARTICALS_SEPARATOR, () => partialsContent.partialsTop) - .replace(HEAD_PARTICALS_SEPARATOR, () => partialsContent.partialsHead) - .replace( - BODY_PARTICALS_SEPARATOR, - () => partialsContent.partialsBody, - ); - - const links = [ - htmlWebpackPlugin.tags.headTags - .filter((item: any) => item.tagName === 'link') - .join(''), - ].join(''); - - const metas = [ - templateParameters.meta, - htmlWebpackPlugin.tags.headTags - .filter( - (item: any) => - item.tagName !== 'script' && - item.tagName !== 'link' && - item.tagName !== 'title', - ) - .join(''), - ].join(''); - - const titles = - htmlWebpackPlugin.tags.headTags - .filter((item: any) => item.tagName === 'title') - .join('') - .replace('', '') - .replace('', '') || templateParameters.title; - - // if the Document.tsx has a functional script, replace to convert it - if ( - html.includes(DOCUMENT_SCRIPT_PLACEHOLDER_START) && - html.includes(DOCUMENT_SCRIPT_PLACEHOLDER_END) - ) { - const { nonce } = config.security || {}; - const nonceAttr = nonce ? `nonce=${nonce}` : ''; - - html = html.replace( - new RegExp( - `${DOCUMENT_SCRIPT_PLACEHOLDER_START}${DOCUMENT_SCRIPT_ATTRIBUTES_START}(.*)${DOCUMENT_SCRIPT_ATTRIBUTES_END}(.*?)${DOCUMENT_SCRIPT_PLACEHOLDER_END}`, - 'g', - ), - (_scriptStr, $1, $2) => - ``, - ); - } - // if the Document.tsx has a style, replace to convert it - if ( - html.includes(DOCUMENT_STYLE_PLACEHOLDER_START) && - html.includes(DOCUMENT_STYLE_PLACEHOLDER_END) - ) { - html = html.replace( - new RegExp( - `${DOCUMENT_STYLE_PLACEHOLDER_START}(.*?)${DOCUMENT_STYLE_PLACEHOLDER_END}`, - 'g', - ), - (_styleStr, $1) => ``, - ); - } - // if the Document.tsx has a comment component, replace and convert it - if ( - html.includes(DOCUMENT_COMMENT_PLACEHOLDER_START) && - html.includes(DOCUMENT_COMMENT_PLACEHOLDER_END) - ) { - html = html.replace( - new RegExp( - `${DOCUMENT_COMMENT_PLACEHOLDER_START}(.*?)${DOCUMENT_COMMENT_PLACEHOLDER_END}`, - 'g', - ), - (_scriptStr, $1) => `${decodeURIComponent($1)}`, - ); - } + const { partialsByEntrypoint } = api.getAppContext(); + html = processPartials(html, entryName, partialsByEntrypoint || {}); + const { scripts, links, metas, titles } = extractHtmlTags( + htmlWebpackPlugin, + templateParameters, + ); - // replace the html placeholder while transfer string to jsx component is not a easy way - const finalHtml = `${html}` - .replace(DOCUMENT_META_PLACEHOLDER, () => metas) - .replace(DOCUMENT_SSR_PLACEHOLDER, () => HTML_SEPARATOR) - .replace(DOCUMENT_SCRIPTS_PLACEHOLDER, () => scripts) - .replace(DOCUMENT_LINKS_PLACEHOLDER, () => links) - .replace( - DOCUMENT_CHUNKSMAP_PLACEHOLDER, - () => PLACEHOLDER_REPLACER_MAP[DOCUMENT_CHUNKSMAP_PLACEHOLDER], - ) - .replace( - DOCUMENT_SSRDATASCRIPT_PLACEHOLDER, - () => PLACEHOLDER_REPLACER_MAP[DOCUMENT_SSRDATASCRIPT_PLACEHOLDER], - ) - .replace(DOCUMENT_TITLE_PLACEHOLDER, () => titles); - return finalHtml; + return processPlaceholders( + html, + config as NormalizedConfig, + scripts, + links, + metas, + titles, + ); }; }; @@ -314,9 +640,8 @@ export const documentPlugin = (): CliPlugin => ({ return { tools: { htmlPlugin: (options, entry) => { - // just for reuse the baseParames calculate by builder: + // reuse builder's computed base parameters // https://github.com/web-infra-dev/modern.js/blob/1abb452a87ae1adbcf8da47d62c05da39cbe4d69/packages/builder/builder-webpack-provider/src/plugins/html.ts#L69-L103 - // TODO: we should use new methods to render document jsx const hackParameters: Record = typeof options?.templateParameters === 'function' ? options?.templateParameters( @@ -329,14 +654,12 @@ export const documentPlugin = (): CliPlugin => ({ const templateContent = documentEntry( entry.entryName, - // options, hackParameters, ); const documentHtmlOptions = templateContent ? { templateContent, - // Note: the behavior of inject/modify tags in afterTemplateExecution hook will not take effect inject: false, } : {}; diff --git a/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts b/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts index f777721c8725..655789f7c9a9 100644 --- a/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts +++ b/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts @@ -181,8 +181,7 @@ function applyFilterEntriesBySSRConfig({ // if prod and ssg config is true or function if ( isProd && - (outputConfig?.ssg === true || - typeof (outputConfig?.ssg as Array)?.[0] === 'function') + (outputConfig?.ssg === true || typeof outputConfig?.ssg === 'function') ) { return; } @@ -202,10 +201,10 @@ function applyFilterEntriesBySSRConfig({ // collect all ssg entries const ssgEntries: string[] = []; - if (isProd && outputConfig?.ssg) { - const { ssg } = outputConfig; + if (isProd && outputConfig?.ssgByEntries) { + const { ssgByEntries } = outputConfig; entryNames.forEach(name => { - if ((ssg as SSGMultiEntryOptions)[name]) { + if (ssgByEntries[name]) { ssgEntries.push(name); } }); diff --git a/packages/solutions/app-tools/src/types/config/output.ts b/packages/solutions/app-tools/src/types/config/output.ts index fa62abf2a465..e5536b2ad64a 100644 --- a/packages/solutions/app-tools/src/types/config/output.ts +++ b/packages/solutions/app-tools/src/types/config/output.ts @@ -1,5 +1,5 @@ import type { BuilderConfig } from '@modern-js/builder'; -import type { SSGConfig } from '@modern-js/types'; +import type { SSGConfig, SSGMultiEntryOptions } from '@modern-js/types'; import type { UnwrapBuilderConfig } from '../utils'; export interface OutputUserConfig @@ -9,6 +9,11 @@ export interface OutputUserConfig * @default false */ ssg?: SSGConfig; + /** + * Specify SSG configuration by entries for multi-entry apps. + * Takes precedence over `ssg` when provided. + */ + ssgByEntries?: SSGMultiEntryOptions; /** * When using convention-based routing, the framework will split js and css based on the route to load on demand. * If your project does not want to split js and css based on routes, you can set this option to false. diff --git a/packages/toolkit/types/cli/index.d.ts b/packages/toolkit/types/cli/index.d.ts index efb80d20d2b1..a207bb8e523b 100644 --- a/packages/toolkit/types/cli/index.d.ts +++ b/packages/toolkit/types/cli/index.d.ts @@ -118,27 +118,29 @@ export type SSGRouteOptions = | { url: string; output?: string; - params?: Record[]; headers?: Record; }; export type SSGSingleEntryOptions = | boolean | { - preventDefault?: string[]; headers?: Record; routes?: SSGRouteOptions[]; }; -export type SSGMultiEntryOptions = Record; +export type SSGSingleEntryOptionsFactory = ( + entryName: string, + ctx: { baseUrl?: string | string[] }, +) => SSGSingleEntryOptions; + +export type SSGMultiEntryOptions = Record< + string, + SSGSingleEntryOptions | SSGSingleEntryOptionsFactory +>; export type SSGConfig = | boolean | SSGSingleEntryOptions - | SSGMultiEntryOptions - | (( - entryName: string, - payload: { baseUrl?: string }, - ) => SSGSingleEntryOptions); + | SSGSingleEntryOptionsFactory; export type { Merge } from 'type-fest'; diff --git a/packages/toolkit/utils/src/cli/is/config.ts b/packages/toolkit/utils/src/cli/is/config.ts index bce1e027aa7b..1ec5e6e37a66 100644 --- a/packages/toolkit/utils/src/cli/is/config.ts +++ b/packages/toolkit/utils/src/cli/is/config.ts @@ -1,4 +1,3 @@ -import type { SSGMultiEntryOptions } from '@modern-js/types'; import { MAIN_ENTRY_NAME } from '../constants'; import { isEmpty } from './type'; @@ -32,7 +31,10 @@ export const isSSR = (config: any): boolean => { export const isUseSSRBundle = (config: any): boolean => { const { output } = config; - if (output?.ssg) { + if ( + output?.ssg || + (output?.ssgByEntries && Object.keys(output?.ssgByEntries).length > 0) + ) { return true; } @@ -64,14 +66,20 @@ export const isSSGEntry = ( entryName: string, entrypoints: EntryPoint[], ) => { - const ssgConfig = config.output.ssg; - const useSSG = isSingleEntry(entrypoints, config.source?.mainEntryName) - ? Boolean(ssgConfig) - : ssgConfig === true || - typeof (ssgConfig as Array)?.[0] === 'function' || - Boolean((ssgConfig as SSGMultiEntryOptions)?.[entryName]); - - return useSSG; + const { output, source } = config; + const single = isSingleEntry(entrypoints, source?.mainEntryName); + + if (single) { + const byEntries = output?.ssgByEntries; + return Boolean(output?.ssg) || (byEntries && !isEmpty(byEntries)); + } + + const byEntries = output?.ssgByEntries; + if (!byEntries || isEmpty(byEntries)) { + return false; + } + + return Boolean(byEntries[entryName]); }; export const isSingleEntry = ( diff --git a/packages/toolkit/utils/tests/is.test.ts b/packages/toolkit/utils/tests/is.test.ts index 1682c25e782d..eecb7a06310a 100644 --- a/packages/toolkit/utils/tests/is.test.ts +++ b/packages/toolkit/utils/tests/is.test.ts @@ -72,14 +72,14 @@ describe('validate', () => { expect(useSSG1).toBeTruthy(); const useSSG2 = isSSGEntry( - { output: { ssg: { home: false } } } as any, + { output: { ssgByEntries: { home: false } } } as any, 'home', [{ entryName: 'main' }, { entryName: 'home' }], ); expect(useSSG2).toBeFalsy(); const useSSG3 = isSSGEntry( - { output: { ssg: { home: {} } } } as any, + { output: { ssgByEntries: { home: {} } } } as any, 'home', [{ entryName: 'main' }, { entryName: 'home' }], ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe254606ea00..75a42723cf6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,9 +458,6 @@ importers: normalize-path: specifier: 3.0.0 version: 3.0.0 - portfinder: - specifier: ^1.0.38 - version: 1.0.38 devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -1416,6 +1413,9 @@ importers: cookie: specifier: 0.7.2 version: 0.7.2 + entities: + specifier: ^7.0.0 + version: 7.0.0 es-module-lexer: specifier: ^1.7.0 version: 1.7.0 @@ -11321,6 +11321,10 @@ packages: resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} engines: {node: '>=0.12'} + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -14113,10 +14117,6 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - portfinder@1.0.38: - resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} - engines: {node: '>= 10.12'} - postcss-calc@9.0.1: resolution: {integrity: sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==} engines: {node: ^14 || ^16 || >=18.0} @@ -23621,6 +23621,8 @@ snapshots: entities@6.0.0: {} + entities@7.0.0: {} + env-paths@2.2.1: {} envinfo@7.14.0: {} @@ -27408,13 +27410,6 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - portfinder@1.0.38: - dependencies: - async: 3.2.6 - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - postcss-calc@9.0.1(postcss@8.5.6): dependencies: postcss: 8.5.6 diff --git a/tests/integration/app-document/modern-rem.config.ts b/tests/integration/app-document/modern-rem.config.ts index 245b910f21f2..389fffc4fdf5 100644 --- a/tests/integration/app-document/modern-rem.config.ts +++ b/tests/integration/app-document/modern-rem.config.ts @@ -7,6 +7,11 @@ export default applyBaseConfig({ html: { favicon: './static/a.icon', }, + source: { + alias: { + '@aliasTest': './src/utils/aliasModule', + }, + }, output: { distPath: { root: 'dist-1', diff --git a/tests/integration/app-document/modern.config.ts b/tests/integration/app-document/modern.config.ts index ba095e962ccf..54a030554be6 100644 --- a/tests/integration/app-document/modern.config.ts +++ b/tests/integration/app-document/modern.config.ts @@ -18,6 +18,11 @@ export default applyBaseConfig({ runtime: { router: true, }, + source: { + alias: { + '@aliasTest': './src/utils/aliasModule', + }, + }, server: { ssrByEntries: { test: true, diff --git a/tests/integration/app-document/src/sub/Document.tsx b/tests/integration/app-document/src/sub/Document.tsx index ae84f096a49d..819fb0217512 100644 --- a/tests/integration/app-document/src/sub/Document.tsx +++ b/tests/integration/app-document/src/sub/Document.tsx @@ -1,3 +1,5 @@ +// @ts-ignore +import { getAliasMessage } from '@aliasTest'; import { Body, Comment, @@ -12,14 +14,12 @@ import { import React, { useContext } from 'react'; export default function Document(): React.ReactElement { - // props: Record, const { config: { output: htmlConfig }, entryName, templateParams, } = useContext(DocumentContext); - console.log('===htmlConfig', htmlConfig); return ( @@ -63,6 +63,7 @@ export default function Document(): React.ReactElement {

title:{htmlConfig.title}

rootId: {templateParams.mountId}

props: {JSON.stringify(htmlConfig)}

+

alias message: {getAliasMessage()}

bottom

diff --git a/tests/integration/app-document/src/utils/aliasModule.ts b/tests/integration/app-document/src/utils/aliasModule.ts new file mode 100644 index 000000000000..bde98fcd7349 --- /dev/null +++ b/tests/integration/app-document/src/utils/aliasModule.ts @@ -0,0 +1,3 @@ +export function getAliasMessage(): string { + return 'Alias module works!'; +} diff --git a/tests/integration/app-document/tests/index.test.ts b/tests/integration/app-document/tests/index.test.ts index b4512dbf4a3c..c1acc94ffda7 100644 --- a/tests/integration/app-document/tests/index.test.ts +++ b/tests/integration/app-document/tests/index.test.ts @@ -17,18 +17,11 @@ function existsSync(filePath: string) { return fs.existsSync(path.join(appDir, 'dist', filePath)); } describe('test dev and build', () => { - const curSequenceWait = new SequenceWait(); - curSequenceWait.add('test-dev'); - curSequenceWait.add('test-rem'); - describe('test build', () => { let buildRes: any; beforeAll(async () => { buildRes = await modernBuild(appDir); }); - afterAll(() => { - curSequenceWait.done('test-dev'); - }); test(`should get right alias build!`, async () => { if (buildRes.code !== 0) { @@ -172,16 +165,25 @@ describe('test dev and build', () => { htmlWithDoc.includes('console.log("this is a IIFE function")'), ).toBe(true); }); + + test('should render alias content in sub html', async () => { + const htmlWithDoc = fs.readFileSync( + path.join(appDir, 'dist', 'html', 'sub', 'index.html'), + 'utf-8', + ); + expect(htmlWithDoc.includes('alias message: Alias module works!')).toBe( + true, + ); + }); }); describe('test dev', () => { let app: any; let appPort: number; - let errors; + let errors: unknown[]; let browser: Browser; let page: Page; beforeAll(async () => { - await curSequenceWait.waitUntil('test-dev'); appPort = await getPort(); app = await launchApp(appDir, appPort, {}, {}); errors = []; @@ -195,7 +197,6 @@ describe('test dev and build', () => { await killApp(app); await page.close(); await browser.close(); - curSequenceWait.done('test-rem'); }); test(`should render page test correctly`, async () => { @@ -236,7 +237,6 @@ describe('test dev and build', () => { describe('fix rem', () => { beforeAll(async () => { - await curSequenceWait.waitUntil('test-rem'); await modernBuild(appDir, ['-c', 'modern-rem.config.ts']); }); diff --git a/tests/integration/ssg/fixtures/mega-list-routes/modern.config.ts b/tests/integration/ssg/fixtures/mega-list-routes/modern.config.ts index 9019befb7ef7..181a6b7faa74 100644 --- a/tests/integration/ssg/fixtures/mega-list-routes/modern.config.ts +++ b/tests/integration/ssg/fixtures/mega-list-routes/modern.config.ts @@ -8,8 +8,7 @@ export default defineConfig({ output: { ssg: { routes: Array.from(Array(10_000)).map((_, id) => ({ - url: '/user/:id', - params: [{ id }], + url: `/user/${id}`, })), }, polyfill: 'off', diff --git a/tests/integration/ssg/fixtures/nested-routes/modern.config.ts b/tests/integration/ssg/fixtures/nested-routes/modern.config.ts index 6258a43678b1..d50dc893209d 100644 --- a/tests/integration/ssg/fixtures/nested-routes/modern.config.ts +++ b/tests/integration/ssg/fixtures/nested-routes/modern.config.ts @@ -6,7 +6,21 @@ export default defineConfig({ router: true, }, output: { - ssg: true, + ssgByEntries: { + main: { + routes: [ + '/user', + { + url: '/', + headers: { + cookies: 'name=modernjs', + }, + }, + '/user/1', + ], + }, + }, + ssg: false, polyfill: 'off', disableTsChecker: true, }, diff --git a/tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.data.ts b/tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.data.ts new file mode 100644 index 000000000000..ddf8ddc82114 --- /dev/null +++ b/tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.data.ts @@ -0,0 +1,15 @@ +// Test dynamic route data loading for SSG +const fs = require('fs'); + +export const loader = ({ params }: { params: { id: string } }) => { + const { id } = params; + + // Simulate data fetching based on user ID + const userData = { + 1: 'User 1: John Doe', + 2: 'User 2: Jane Smith', + 3: 'User 3: Bob Johnson', + }; + + return userData[id] || `User ${id}: Unknown User`; +}; diff --git a/tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.jsx b/tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.jsx new file mode 100644 index 000000000000..32439a71d7ff --- /dev/null +++ b/tests/integration/ssg/fixtures/nested-routes/src/routes/user/[id]/page.jsx @@ -0,0 +1,15 @@ +import { useLoaderData, useParams } from '@modern-js/runtime/router'; + +const UserDetail = () => { + const data = useLoaderData(); + const params = useParams(); + + return ( +
+
{data}
+
User ID: {params.id}
+
+ ); +}; + +export default UserDetail; diff --git a/tests/integration/ssg/fixtures/nested-routes/src/routes/user/layout.tsx b/tests/integration/ssg/fixtures/nested-routes/src/routes/user/layout.tsx new file mode 100644 index 000000000000..29ceaa82a222 --- /dev/null +++ b/tests/integration/ssg/fixtures/nested-routes/src/routes/user/layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from '@modern-js/runtime/router'; + +export default function UserLayout() { + return ( +
+ +
+ ); +} diff --git a/tests/integration/ssg/tests/nested-routes.test.ts b/tests/integration/ssg/tests/nested-routes.test.ts index df168cb8df6a..82fc72befc30 100644 --- a/tests/integration/ssg/tests/nested-routes.test.ts +++ b/tests/integration/ssg/tests/nested-routes.test.ts @@ -35,6 +35,13 @@ describe('ssg', () => { const html = (await fs.readFile(htmlPath)).toString(); expect(html.includes('Hello, User')).toBe(true); }); + + test('should nested-routes ssg access /user/1 work correctly with data loading', async () => { + const htmlPath = path.join(distDir, 'html/main/user/1/index.html'); + const html = (await fs.readFile(htmlPath)).toString(); + expect(html.includes('User 1: John Doe')).toBe(true); + expect(html.includes('User ID: 1')).toBe(true); + }); }); describe('test ssg request', () => { @@ -70,4 +77,28 @@ describe('test ssg request', () => { await browser.close(); } }); + + test('should visit dynamic route /user/1 correctly with data loading', async () => { + const host = `http://localhost`; + expect(buildRes.code === 0).toBe(true); + const browser = await puppeteer.launch(launchOptions as any); + const page = await browser.newPage(); + await page.goto(`${host}:${port}/user/1`); + + const dataElement = await page.$('#data'); + const paramsElement = await page.$('#params'); + const dataText = await page.evaluate(el => el?.textContent, dataElement); + const paramsText = await page.evaluate( + el => el?.textContent, + paramsElement, + ); + + try { + expect(dataText?.trim()).toEqual('User 1: John Doe'); + expect(paramsText?.trim()).toEqual('User ID: 1'); + } finally { + await page.close(); + await browser.close(); + } + }); }); From e34bd3da533ca57b9f173796cf294a8cfbb8170b Mon Sep 17 00:00:00 2001 From: wangyiming Date: Thu, 18 Sep 2025 15:24:49 +0800 Subject: [PATCH 2/3] chore: doc and types --- packages/cli/plugin-ssg/src/index.ts | 6 +++--- .../document/main-doc/docs/en/configure/app/output/ssg.mdx | 6 +++--- .../document/main-doc/docs/zh/configure/app/output/ssg.mdx | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/plugin-ssg/src/index.ts b/packages/cli/plugin-ssg/src/index.ts index 84583532760a..16f1b6ed9d32 100644 --- a/packages/cli/plugin-ssg/src/index.ts +++ b/packages/cli/plugin-ssg/src/index.ts @@ -56,7 +56,7 @@ export const ssgPlugin = (): CliPlugin => ({ } = output; const ssgOptions: SSGConfig = - (Array.isArray(ssg) ? (ssg as any[]).pop() : (ssg as any)) ?? true; + (Array.isArray(ssg) ? ssg.pop() : ssg) ?? true; const buildDir = path.join(appDirectory, outputPath as string); const routes = readJSONSpec(buildDir); @@ -115,13 +115,13 @@ export const ssgPlugin = (): CliPlugin => ({ } if (entryOptions === true) { - entryOptions = { routes: [], headers: {} } as any; + entryOptions = { routes: [], headers: {} }; } const { routes: userRoutes = [], headers } = (entryOptions as { routes?: SSGRouteOptions[]; - headers?: Record; + headers?: Record; }) || {}; // if the user sets the routes, then only add them if (userRoutes.length > 0) { diff --git a/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx b/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx index 5fc6d3efdb76..4a14c182ce3e 100644 --- a/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx +++ b/packages/document/main-doc/docs/en/configure/app/output/ssg.mdx @@ -9,12 +9,12 @@ title: ssg Configuration to enable the application’s SSG (Static Site Generation) feature. -:::tip +:::tip Enabling SSG This configuration takes effect only when SSG is enabled. Please read the [Static Site Generation](/guides/basic-features/render/ssg) documentation to understand how to enable SSG and its use cases. ::: -:::info +:::info Recommended Reading The SSG feature is closely related to routing. It is recommended to understand the [routing solution](/guides/basic-features/routes) before using SSG. ::: @@ -64,7 +64,7 @@ In the example configuration below, SSG will render the pages corresponding to t For the `/user/:id` route, `cookies` will be added to the request headers. -```ts title="modern.config.ts (single entry)" +```ts title="modern.config.ts" export default defineConfig({ output: { ssg: { diff --git a/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx b/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx index 474a523772f5..70e4dc02c2c7 100644 --- a/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx +++ b/packages/document/main-doc/docs/zh/configure/app/output/ssg.mdx @@ -9,12 +9,12 @@ title: ssg 开启应用 SSG 功能的配置 -:::tip +:::tip 开启 SSG 功能 此配置需要在开启 SSG 功能情况下才会生效。请阅读 [静态站点生成](/guides/basic-features/render/ssg) 文档了解如何开启 SSG 功能及使用场景。 ::: -:::info +:::info 前置阅读 SSG 功能使用与路由关联性较大,建议使用前先了解[路由方案](/guides/basic-features/routes)。 ::: From 3c8c876dbc82e8e3c4acca24d29c46b8b8eb343f Mon Sep 17 00:00:00 2001 From: wangyiming Date: Tue, 23 Sep 2025 15:20:38 +0800 Subject: [PATCH 3/3] chore: update error message --- packages/runtime/plugin-runtime/src/document/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/plugin-runtime/src/document/cli/index.ts b/packages/runtime/plugin-runtime/src/document/cli/index.ts index 051a81b2de76..ef50256e72b3 100644 --- a/packages/runtime/plugin-runtime/src/document/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/document/cli/index.ts @@ -471,7 +471,7 @@ export const documentPlugin = (): CliPlugin => ({ const cached = entryName2DocCode.get(entryName); if (!cached) { throw new Error( - `Document bundle for entry "${entryName}" not found. The document will not override html templateContent.`, + `Failed to load Document bundle for entry "${entryName}". This is likely because the Document component compilation failed or the bundle was not generated correctly. Please check your Document component implementation.`, ); }