@ubean/build 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/prerender.d.ts +12 -0
- package/dist/prerender.js +9 -5
- package/dist/production.js +302 -73
- package/dist/static-render.d.ts +101 -0
- package/dist/static-render.js +254 -0
- package/dist/{vue-I5nC0ldj.js → vue-CIj7ErAs.js} +13 -11
- package/dist/vue.js +1 -1
- package/package.json +16 -12
package/dist/prerender.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ interface PrerendererOptions {
|
|
|
21
21
|
routeRules?: Record<string, RouteRule$1>;
|
|
22
22
|
/** Content collection URLs merged into `include` (see `extractContentPageRoutes`). */
|
|
23
23
|
contentRoutes?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* 静态 SSG 模式:将 404 哨兵路由加入预渲染队列,产物为 `404.html`
|
|
26
|
+
* (供 GitHub Pages / Netlify 等静态托管平台的自定义 404 页)。
|
|
27
|
+
* fetcher 需识别 `STATIC_NOT_FOUND_ROUTE` 并渲染 notFoundPage。
|
|
28
|
+
*/
|
|
29
|
+
notFoundRoute?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* i18n 多语言展开钩子(docs/adr/0011-lightweight-ssg-direct-render.md):在 `collectPrerenderRoutes`
|
|
32
|
+
* 之后、入队之前应用,把收集到的路由扩展为全语言 URL 列表
|
|
33
|
+
* (如 `/about` → `['/about', '/zh/about']`)。
|
|
34
|
+
*/
|
|
35
|
+
expandRoutes?: (routes: string[]) => string[];
|
|
24
36
|
fetcher?: (url: string) => Promise<{
|
|
25
37
|
html: string;
|
|
26
38
|
statusCode: number;
|
package/dist/prerender.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { STATIC_NOT_FOUND_ROUTE } from "./static-render.js";
|
|
1
2
|
import { dirname, join } from "pathe";
|
|
2
3
|
import { resolvePrerenderConfig } from "@ubean/config";
|
|
3
4
|
import { DATA_PAYLOAD_ID } from "@ubean/pages";
|
|
@@ -127,6 +128,7 @@ function normalizeHref(href, baseUrl) {
|
|
|
127
128
|
*/
|
|
128
129
|
function routeToFilePath(route, outputDir) {
|
|
129
130
|
if (route === "/" || route === "") return join(outputDir, "index.html");
|
|
131
|
+
if (route === "/__ubean_404__") return join(outputDir, "404.html");
|
|
130
132
|
if (route.endsWith(".html")) return join(outputDir, route);
|
|
131
133
|
const lastSegment = route.slice(route.lastIndexOf("/") + 1);
|
|
132
134
|
const dotIdx = lastSegment.lastIndexOf(".");
|
|
@@ -213,7 +215,8 @@ async function prerender(options) {
|
|
|
213
215
|
routeRules: options.routeRules,
|
|
214
216
|
contentRoutes: options.contentRoutes
|
|
215
217
|
});
|
|
216
|
-
const queue = [...initialRoutes];
|
|
218
|
+
const queue = [...options.expandRoutes ? options.expandRoutes(initialRoutes) : initialRoutes];
|
|
219
|
+
if (options.notFoundRoute) queue.unshift(STATIC_NOT_FOUND_ROUTE);
|
|
217
220
|
const visited = /* @__PURE__ */ new Set();
|
|
218
221
|
const results = [];
|
|
219
222
|
const generated = [];
|
|
@@ -223,11 +226,12 @@ async function prerender(options) {
|
|
|
223
226
|
async function processRoute(route) {
|
|
224
227
|
if (visited.has(route)) return;
|
|
225
228
|
visited.add(route);
|
|
226
|
-
if (config.exclude.some((p) => matchGlob(route, p))) {
|
|
229
|
+
if (route !== "/__ubean_404__" && config.exclude.some((p) => matchGlob(route, p))) {
|
|
227
230
|
if (!skipped.includes(route)) skipped.push(route);
|
|
228
231
|
return;
|
|
229
232
|
}
|
|
230
|
-
const
|
|
233
|
+
const displayRoute = route === "/__ubean_404__" ? "/404" : route;
|
|
234
|
+
const result = { route: displayRoute };
|
|
231
235
|
try {
|
|
232
236
|
if (options.fetcher) {
|
|
233
237
|
const resp = await options.fetcher(route);
|
|
@@ -253,7 +257,7 @@ async function prerender(options) {
|
|
|
253
257
|
}
|
|
254
258
|
}
|
|
255
259
|
await writePrerenderedFile(routeToFilePath(route, outputDir), htmlToWrite);
|
|
256
|
-
generated.push(
|
|
260
|
+
generated.push(displayRoute);
|
|
257
261
|
result.html = void 0;
|
|
258
262
|
} else {
|
|
259
263
|
const placeholderHtml = `<!DOCTYPE html>
|
|
@@ -264,7 +268,7 @@ async function prerender(options) {
|
|
|
264
268
|
</body>
|
|
265
269
|
</html>`;
|
|
266
270
|
await writePrerenderedFile(routeToFilePath(route, outputDir), placeholderHtml);
|
|
267
|
-
generated.push(
|
|
271
|
+
generated.push(displayRoute);
|
|
268
272
|
result.statusCode = 200;
|
|
269
273
|
}
|
|
270
274
|
} catch (err) {
|
package/dist/production.js
CHANGED
|
@@ -2,7 +2,7 @@ import { a as useVirtualRegistry } from "./virtual-registry-BWkaQHXN.js";
|
|
|
2
2
|
import { a as createRoutingVirtualModule, i as createPagesVirtualModule, n as createLocalesVirtualModule, r as createMetaVirtualModule, t as createAppVirtualModule } from "./virtual-modules-vLkLkAfT.js";
|
|
3
3
|
import { a as ssrSingletonProdOptimizeExclude, o as ssrSingletonProdSsr } from "./ssr-singleton-CEQi_H6-.js";
|
|
4
4
|
import { n as localeVueParamFromI18n, r as serializeI18nConfig, t as ubeanPlugin } from "./vite-D2EZl_6P.js";
|
|
5
|
-
import { a as createVueAppEntryVirtualModule, i as createServerEntryVirtualModule, n as ubeanVite, o as createVuePagesVirtualModule, r as createClientEntryVirtualModule, t as VUE_PLUGIN_INCLUDE } from "./vue-
|
|
5
|
+
import { a as createVueAppEntryVirtualModule, i as createServerEntryVirtualModule, n as ubeanVite, o as createVuePagesVirtualModule, r as createClientEntryVirtualModule, t as VUE_PLUGIN_INCLUDE } from "./vue-CIj7ErAs.js";
|
|
6
6
|
import { join, relative, resolve } from "pathe";
|
|
7
7
|
import { resolveModules } from "@ubean/config";
|
|
8
8
|
import { build } from "vite";
|
|
@@ -14,6 +14,282 @@ import { ubeanIslandsPlugin } from "@ubean/islands/vite";
|
|
|
14
14
|
import { isEphemeralCachePreset, resolveProductionCacheStore } from "@ubean/preset";
|
|
15
15
|
import { getLogger } from "@ubean/shared/logger";
|
|
16
16
|
import { findUserViteConfig } from "@ubean/shared/node";
|
|
17
|
+
//#region src/ssg-entry.ts
|
|
18
|
+
/**
|
|
19
|
+
* 生成 renderer setup 代码块(`_rendererRoutes` + `_pageRenderer`)。
|
|
20
|
+
*
|
|
21
|
+
* 依赖外层已定义:`_pages` / `_layouts` / `pageLoaders` / `layoutLoaders` /
|
|
22
|
+
* (notFoundCatchAll 时)`_notFoundPage`,以及 `toVueRouterLocalePath` 导入。
|
|
23
|
+
* ssrEnabled=false 时 `_pageRenderer = null`(与 fullstack 现状一致)。
|
|
24
|
+
*/
|
|
25
|
+
function buildRendererSetup(options) {
|
|
26
|
+
const { ssrEnabled, mode, localeVueParam, notFoundCatchAll } = options;
|
|
27
|
+
if (!ssrEnabled) return `
|
|
28
|
+
// --- SSR disabled (mode=${mode}) ---
|
|
29
|
+
// pageRenderer is null; page requests fall back to a client-only HTML shell.
|
|
30
|
+
const _pageRenderer = null;`;
|
|
31
|
+
return `
|
|
32
|
+
// --- SSR renderer setup ---
|
|
33
|
+
const _defaultLayout = _layouts.find(l => l.isDefault)?.name || null;
|
|
34
|
+
|
|
35
|
+
// Same i18n locale param as the client virtual:ubean-pages route table, so
|
|
36
|
+
// language-prefixed URLs (e.g. /zh/playground) match the real page on SSR too.
|
|
37
|
+
const _localeVueParam = ${JSON.stringify(localeVueParam)};
|
|
38
|
+
|
|
39
|
+
const _rendererRoutes = _pages.map(p => {
|
|
40
|
+
// For reuse routes, load the target page's module (the .reuse.ts file
|
|
41
|
+
// only contains definePage metadata, not a Vue component).
|
|
42
|
+
const _targetPage = p.isReuse && p.reuseTarget ? _pages.find(tp => tp.name === p.reuseTarget) : undefined;
|
|
43
|
+
const _loaderKey = _targetPage?.relativePath || p.relativePath;
|
|
44
|
+
return {
|
|
45
|
+
path: _localeVueParam
|
|
46
|
+
? toVueRouterLocalePath(p.route.replace(/\\*\\*:(\\w[\\w-]*)/g, ':$1(.*)*'), _localeVueParam)
|
|
47
|
+
: p.route.replace(/\\*\\*:(\\w[\\w-]*)/g, ':$1(.*)*'),
|
|
48
|
+
name: p.name,
|
|
49
|
+
component: async () => {
|
|
50
|
+
const loader = pageLoaders[_loaderKey];
|
|
51
|
+
if (!loader) throw new Error('Page loader not found: ' + _loaderKey);
|
|
52
|
+
const mod = await loader();
|
|
53
|
+
return mod.default || mod;
|
|
54
|
+
},
|
|
55
|
+
meta: {
|
|
56
|
+
layout: p.layout === false ? false : p.layout || _defaultLayout,
|
|
57
|
+
pageName: p.name,
|
|
58
|
+
cache: p.cache === true ? true : undefined
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
});${notFoundCatchAll ? `
|
|
62
|
+
|
|
63
|
+
// 404 渲染路由:注册为静态路径 /404(而非客户端的 /:pathMatch(.*)* catch-all)。
|
|
64
|
+
// vue-router 中静态段确定性优先于 repeatable 参数 —— 即使应用存在根 catch-all
|
|
65
|
+
// 页面(如 [...slug].vue),/404 也稳定命中本路由,保证 404.html 产物
|
|
66
|
+
// 渲染的是 pages/404.vue 而非被 catch-all 抢占。
|
|
67
|
+
if (_notFoundPage) {
|
|
68
|
+
const _nfLoaderKey = _notFoundPage.relativePath;
|
|
69
|
+
_rendererRoutes.push({
|
|
70
|
+
path: _localeVueParam
|
|
71
|
+
? toVueRouterLocalePath('/404', _localeVueParam)
|
|
72
|
+
: '/404',
|
|
73
|
+
name: 'NotFound',
|
|
74
|
+
component: async () => {
|
|
75
|
+
const loader = pageLoaders[_nfLoaderKey];
|
|
76
|
+
if (!loader) throw new Error('Page loader not found: ' + _nfLoaderKey);
|
|
77
|
+
const mod = await loader();
|
|
78
|
+
return mod.default || mod;
|
|
79
|
+
},
|
|
80
|
+
meta: { pageName: 'NotFound' }
|
|
81
|
+
});
|
|
82
|
+
}` : ""}
|
|
83
|
+
|
|
84
|
+
const _layoutMap = new Map();
|
|
85
|
+
for (const l of _layouts) {
|
|
86
|
+
_layoutMap.set(l.name, l.relativePath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const _pageRenderer = createVueRenderer({
|
|
90
|
+
routes: _rendererRoutes,
|
|
91
|
+
async resolveLayoutComponent(name) {
|
|
92
|
+
if (name === false || name == null) return null;
|
|
93
|
+
const relPath = _layoutMap.get(name);
|
|
94
|
+
if (!relPath) return null;
|
|
95
|
+
const loader = layoutLoaders[relPath];
|
|
96
|
+
if (!loader) return null;
|
|
97
|
+
const mod = await loader();
|
|
98
|
+
return mod.default || mod;
|
|
99
|
+
},
|
|
100
|
+
defaultLayout: _defaultLayout,
|
|
101
|
+
resolveAppConfig: () => _resolveAppConfig('server')
|
|
102
|
+
});
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 生成 client asset tags 代码块(读取 `.vite/manifest.json`)。
|
|
107
|
+
* 依赖外层已定义 `readFileSync` / `join` / `dirname` / `fileURLToPath` 导入。
|
|
108
|
+
*/
|
|
109
|
+
function buildAssetTagsSetup(favicon) {
|
|
110
|
+
return `
|
|
111
|
+
// --- Client asset tags from Vite manifest ---
|
|
112
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
113
|
+
let _assetTags = { css: '', preloads: '', body: '', favicon: ${JSON.stringify(favicon)} };
|
|
114
|
+
try {
|
|
115
|
+
const manifestPath = join(__dirname, '..', 'public', '.vite', 'manifest.json');
|
|
116
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
117
|
+
const entry = Object.values(manifest).find(m => m.isEntry);
|
|
118
|
+
if (entry) {
|
|
119
|
+
_assetTags.body = '<script type="module" src="/' + entry.file + '"><\/script>';
|
|
120
|
+
if (Array.isArray(entry.css)) {
|
|
121
|
+
_assetTags.css = entry.css.map(c => '<link rel="stylesheet" href="/' + c + '">').join('\\n');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.warn('[ubean] Failed to load client manifest:', e.message || e);
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 生成静态 SSG entry 源码(写入 virtualDir/server-entry.mjs,由 SSR build
|
|
131
|
+
* 产出 dist/server/entry.mjs)。
|
|
132
|
+
*
|
|
133
|
+
* 导出契约:
|
|
134
|
+
* - `renderStaticPage({ pageName, params, url, locale }) → { html, statusCode }`
|
|
135
|
+
* - `pageLoaders`(调试用)
|
|
136
|
+
*/
|
|
137
|
+
function buildStaticSsgEntry(input) {
|
|
138
|
+
const { pagesGlob, layoutsGlob, srcPrefix, pagesJson, layoutsJson, notFoundPageJson, localeVueParam, contentBootstrap, colorModeScript, favicon } = input;
|
|
139
|
+
return `// Auto-generated ubean static SSG entry (mode: ssg)
|
|
140
|
+
import { renderPage } from '@ubean/pages';
|
|
141
|
+
import { createVueRenderer } from 'ubean/ssr';
|
|
142
|
+
import { toVueRouterLocalePath } from '@ubean/i18n';
|
|
143
|
+
import { i18nConfig as _i18nConfig, loadLocales as _loadLocales } from 'ubean:locales';
|
|
144
|
+
import { resolveAppConfig as _resolveAppConfig } from 'virtual:ubean-app';
|
|
145
|
+
${contentBootstrap}
|
|
146
|
+
import { readFileSync } from 'node:fs';
|
|
147
|
+
import { join, dirname } from 'node:path';
|
|
148
|
+
import { fileURLToPath } from 'node:url';
|
|
149
|
+
|
|
150
|
+
const pageModules = import.meta.glob(${pagesGlob}, { eager: false });
|
|
151
|
+
const layoutModules = import.meta.glob(${layoutsGlob}, { eager: false });
|
|
152
|
+
|
|
153
|
+
const _srcPrefix = ${srcPrefix};
|
|
154
|
+
function normalizeKey(p) {
|
|
155
|
+
const prefixes = ['/pages/', '/layouts/'];
|
|
156
|
+
for (const prefix of prefixes) {
|
|
157
|
+
const fullPrefix = _srcPrefix + prefix;
|
|
158
|
+
if (p.includes(fullPrefix)) {
|
|
159
|
+
const idx = p.indexOf(fullPrefix);
|
|
160
|
+
return p.slice(idx + fullPrefix.length);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const pageLoaders = {};
|
|
167
|
+
for (const [key, loader] of Object.entries(pageModules)) {
|
|
168
|
+
pageLoaders[normalizeKey(key)] = loader;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const layoutLoaders = {};
|
|
172
|
+
for (const [key, loader] of Object.entries(layoutModules)) {
|
|
173
|
+
layoutLoaders[normalizeKey(key)] = loader;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const _pages = ${pagesJson};
|
|
177
|
+
const _layouts = ${layoutsJson};
|
|
178
|
+
const _notFoundPage = ${notFoundPageJson};
|
|
179
|
+
const _hasDefaultLayout = _layouts.some(l => l.isDefault);
|
|
180
|
+
${buildRendererSetup({
|
|
181
|
+
ssrEnabled: true,
|
|
182
|
+
mode: "ssg",
|
|
183
|
+
localeVueParam,
|
|
184
|
+
notFoundCatchAll: true
|
|
185
|
+
})}
|
|
186
|
+
${buildAssetTagsSetup(favicon)}
|
|
187
|
+
const _colorModeScript = ${JSON.stringify(colorModeScript)};
|
|
188
|
+
|
|
189
|
+
// --- i18n ---
|
|
190
|
+
const _i18nEnabled = _i18nConfig && _i18nConfig.enabled !== false;
|
|
191
|
+
const _localeCodes = _i18nEnabled
|
|
192
|
+
? (_i18nConfig.locales || []).map(l => (typeof l === 'string' ? l : l.code))
|
|
193
|
+
: [];
|
|
194
|
+
const _defaultLocale = (_i18nEnabled && _i18nConfig.defaultLocale) || '';
|
|
195
|
+
let _localesReady = false;
|
|
196
|
+
|
|
197
|
+
async function _ensureLocales() {
|
|
198
|
+
if (_localesReady) return;
|
|
199
|
+
_localesReady = true;
|
|
200
|
+
if (!_i18nEnabled || _localeCodes.length === 0) return;
|
|
201
|
+
try {
|
|
202
|
+
await _loadLocales();
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.warn('[ubean-ssg] Failed to load locales:', (e && e.message) || e);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 静态模式不执行 loader —— 检测到页面导出 loader 时警告一次(每页)
|
|
209
|
+
const _loaderWarned = new Set();
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 渲染单个静态页面(由 createStaticSsgRenderer 调用)。
|
|
213
|
+
*
|
|
214
|
+
* @param input.pageName 目标页面名(ScannedPageRoute.name)或 'NotFound'
|
|
215
|
+
* @param input.params 路由参数(matchRoutePattern 提取)
|
|
216
|
+
* @param input.url 完整 URL path(含 locale 前缀与 query)
|
|
217
|
+
* @param input.locale 渲染语言(prefix 策略下已从 URL 提取)
|
|
218
|
+
*/
|
|
219
|
+
export async function renderStaticPage(input) {
|
|
220
|
+
const pageName = input && input.pageName;
|
|
221
|
+
const page = pageName === 'NotFound' ? _notFoundPage : _pages.find(p => p.name === pageName);
|
|
222
|
+
if (!page) return { html: '', statusCode: 404 };
|
|
223
|
+
|
|
224
|
+
await _ensureLocales();
|
|
225
|
+
|
|
226
|
+
// loader 检测 warn(模块加载有缓存,此处与渲染共用同一实例)
|
|
227
|
+
const _targetPage =
|
|
228
|
+
page.isReuse && page.reuseTarget ? _pages.find(tp => tp.name === page.reuseTarget) : undefined;
|
|
229
|
+
const _loaderKey = (_targetPage && _targetPage.relativePath) || page.relativePath;
|
|
230
|
+
const _modLoader = pageLoaders[_loaderKey];
|
|
231
|
+
if (_modLoader && !_loaderWarned.has(page.name)) {
|
|
232
|
+
try {
|
|
233
|
+
const mod = await _modLoader();
|
|
234
|
+
if (mod && typeof mod.loader === 'function') {
|
|
235
|
+
_loaderWarned.add(page.name);
|
|
236
|
+
console.warn(
|
|
237
|
+
'[ubean-ssg] page "' + page.name + '" exports \`loader\`, which is not executed in static SSG mode'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// renderContext 构造与 routes/router.ts 的页面渲染路径保持一致
|
|
244
|
+
const renderContext = {};
|
|
245
|
+
const locale = (input && input.locale) || _defaultLocale;
|
|
246
|
+
if (_i18nEnabled && locale) {
|
|
247
|
+
renderContext.locale = locale;
|
|
248
|
+
renderContext.localeDir = 'ltr';
|
|
249
|
+
renderContext.routing = {
|
|
250
|
+
defaultLocale: _i18nConfig.defaultLocale || 'en',
|
|
251
|
+
locales: _localeCodes,
|
|
252
|
+
strategy: _i18nConfig.strategy || 'prefix_except_default'
|
|
253
|
+
};
|
|
254
|
+
const cookieName =
|
|
255
|
+
_i18nConfig.detectBrowserLanguage && _i18nConfig.detectBrowserLanguage.cookieName;
|
|
256
|
+
if (cookieName) renderContext.cookieName = cookieName;
|
|
257
|
+
if (_i18nConfig.baseUrl) renderContext.baseUrl = _i18nConfig.baseUrl;
|
|
258
|
+
try {
|
|
259
|
+
const i18nMod = await import('ubean/i18n');
|
|
260
|
+
renderContext.localeDir = i18nMod.getLocaleDir(locale);
|
|
261
|
+
renderContext.messages = i18nMod.getLocaleMessages(locale);
|
|
262
|
+
const fallback = i18nMod.getFallbackLocale();
|
|
263
|
+
renderContext.fallbackLocale = fallback;
|
|
264
|
+
if (fallback !== locale) {
|
|
265
|
+
renderContext.fallbackMessages = i18nMod.getLocaleMessages(fallback);
|
|
266
|
+
}
|
|
267
|
+
renderContext.availableLocales = i18nMod.getRegisteredLocalesMeta();
|
|
268
|
+
} catch {}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// pageObj 构造与 routes/router.ts handlePageRequest 保持一致
|
|
272
|
+
const pageObj = {
|
|
273
|
+
component: page.reuseTarget || page.name,
|
|
274
|
+
props: {},
|
|
275
|
+
params: (input && input.params) || {},
|
|
276
|
+
url: (input && input.url) || '/',
|
|
277
|
+
layout: page.layout === false
|
|
278
|
+
? false
|
|
279
|
+
: page.layout || (page.pageMeta && page.pageMeta.layout) || (_hasDefaultLayout ? 'default' : false),
|
|
280
|
+
errors: null,
|
|
281
|
+
head: page.pageMeta && page.pageMeta.head
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
let html = await renderPage(pageObj, _assetTags, _pageRenderer, 'app', renderContext);
|
|
285
|
+
if (_colorModeScript) {
|
|
286
|
+
html = html.replace('<head>', '<head>\\n ' + _colorModeScript);
|
|
287
|
+
}
|
|
288
|
+
return { html, statusCode: 200 };
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
17
293
|
//#region src/production.ts
|
|
18
294
|
const logger = getLogger("build");
|
|
19
295
|
function getOutDirs(cwd, outputDir) {
|
|
@@ -134,61 +410,12 @@ async function generateVirtualModulesToDisk(cwd, config, scanResult, virtualDir,
|
|
|
134
410
|
const colorModeScript = config.colorMode !== false ? getColorModeScript(resolveColorModeConfig(config.colorMode)) : "";
|
|
135
411
|
const rendererImport = ssrEnabled ? `import { createVueRenderer } from 'ubean/ssr';` : "";
|
|
136
412
|
const prodLocaleVueParam = localeVueParamFromI18n(config.i18n) || "";
|
|
137
|
-
const rendererSetup =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const _localeVueParam = ${JSON.stringify(prodLocaleVueParam)};
|
|
144
|
-
|
|
145
|
-
const _rendererRoutes = _pages.map(p => {
|
|
146
|
-
// For reuse routes, load the target page's module (the .reuse.ts file
|
|
147
|
-
// only contains definePage metadata, not a Vue component).
|
|
148
|
-
const _targetPage = p.isReuse && p.reuseTarget ? _pages.find(tp => tp.name === p.reuseTarget) : undefined;
|
|
149
|
-
const _loaderKey = _targetPage?.relativePath || p.relativePath;
|
|
150
|
-
return {
|
|
151
|
-
path: _localeVueParam
|
|
152
|
-
? toVueRouterLocalePath(p.route.replace(/\\*\\*:(\\w[\\w-]*)/g, ':$1(.*)*'), _localeVueParam)
|
|
153
|
-
: p.route.replace(/\\*\\*:(\\w[\\w-]*)/g, ':$1(.*)*'),
|
|
154
|
-
name: p.name,
|
|
155
|
-
component: async () => {
|
|
156
|
-
const loader = pageLoaders[_loaderKey];
|
|
157
|
-
if (!loader) throw new Error('Page loader not found: ' + _loaderKey);
|
|
158
|
-
const mod = await loader();
|
|
159
|
-
return mod.default || mod;
|
|
160
|
-
},
|
|
161
|
-
meta: {
|
|
162
|
-
layout: p.layout === false ? false : p.layout || _defaultLayout,
|
|
163
|
-
pageName: p.name,
|
|
164
|
-
cache: p.cache === true ? true : undefined
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const _layoutMap = new Map();
|
|
170
|
-
for (const l of _layouts) {
|
|
171
|
-
_layoutMap.set(l.name, l.relativePath);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const _pageRenderer = createVueRenderer({
|
|
175
|
-
routes: _rendererRoutes,
|
|
176
|
-
async resolveLayoutComponent(name) {
|
|
177
|
-
if (name === false || name == null) return null;
|
|
178
|
-
const relPath = _layoutMap.get(name);
|
|
179
|
-
if (!relPath) return null;
|
|
180
|
-
const loader = layoutLoaders[relPath];
|
|
181
|
-
if (!loader) return null;
|
|
182
|
-
const mod = await loader();
|
|
183
|
-
return mod.default || mod;
|
|
184
|
-
},
|
|
185
|
-
defaultLayout: _defaultLayout,
|
|
186
|
-
resolveAppConfig: () => _resolveAppConfig('server')
|
|
187
|
-
});
|
|
188
|
-
` : `
|
|
189
|
-
// --- SSR disabled (mode=${mode}, ssr=${config.ssr.enabled}) ---
|
|
190
|
-
// pageRenderer is null; page requests fall back to a client-only HTML shell.
|
|
191
|
-
const _pageRenderer = null;`;
|
|
413
|
+
const rendererSetup = buildRendererSetup({
|
|
414
|
+
ssrEnabled,
|
|
415
|
+
mode,
|
|
416
|
+
localeVueParam: prodLocaleVueParam
|
|
417
|
+
});
|
|
418
|
+
const assetTagsSetup = buildAssetTagsSetup(config.favicon);
|
|
192
419
|
const productionCache = resolveProductionCacheStore(preset.name || "standard", config.cache);
|
|
193
420
|
const contentEntries = Object.entries(contentSnapshot ?? {}).filter(([, docs]) => Array.isArray(docs));
|
|
194
421
|
const contentBootstrap = contentEntries.length > 0 ? `
|
|
@@ -201,6 +428,22 @@ ${contentEntries.map(([name, docs]) => `registerContent(${JSON.stringify(name)},
|
|
|
201
428
|
const cronImport = enableInProcessCron ? `import { startCronScheduler } from '@ubean/server/cron';
|
|
202
429
|
` : "";
|
|
203
430
|
if (hasServer) {
|
|
431
|
+
if (mode === "ssg") {
|
|
432
|
+
const staticEntry = buildStaticSsgEntry({
|
|
433
|
+
pagesGlob,
|
|
434
|
+
layoutsGlob,
|
|
435
|
+
srcPrefix: JSON.stringify(viteSrcPrefix),
|
|
436
|
+
pagesJson,
|
|
437
|
+
layoutsJson,
|
|
438
|
+
notFoundPageJson,
|
|
439
|
+
localeVueParam: prodLocaleVueParam,
|
|
440
|
+
contentBootstrap,
|
|
441
|
+
colorModeScript,
|
|
442
|
+
favicon: config.favicon
|
|
443
|
+
});
|
|
444
|
+
await writeFile(join(virtualDir, "server-entry.mjs"), staticEntry, "utf-8");
|
|
445
|
+
return virtualDir;
|
|
446
|
+
}
|
|
204
447
|
const serverEntry = `// Auto-generated server entry
|
|
205
448
|
import { createUbeanApp, applyServerConfig } from 'ubean/server';
|
|
206
449
|
import { toVueRouterLocalePath } from '@ubean/i18n';
|
|
@@ -260,22 +503,7 @@ const _pages = ${pagesJson};
|
|
|
260
503
|
const _layouts = ${layoutsJson};
|
|
261
504
|
const _notFoundPage = ${notFoundPageJson};
|
|
262
505
|
${rendererSetup}
|
|
263
|
-
|
|
264
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
265
|
-
let _assetTags = { css: '', preloads: '', body: '', favicon: ${JSON.stringify(config.favicon)} };
|
|
266
|
-
try {
|
|
267
|
-
const manifestPath = join(__dirname, '..', 'public', '.vite', 'manifest.json');
|
|
268
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
269
|
-
const entry = Object.values(manifest).find(m => m.isEntry);
|
|
270
|
-
if (entry) {
|
|
271
|
-
_assetTags.body = '<script type="module" src="/' + entry.file + '"><\/script>';
|
|
272
|
-
if (Array.isArray(entry.css)) {
|
|
273
|
-
_assetTags.css = entry.css.map(c => '<link rel="stylesheet" href="/' + c + '">').join('\\n');
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
} catch (e) {
|
|
277
|
-
console.warn('[ubean] Failed to load client manifest:', e.message || e);
|
|
278
|
-
}
|
|
506
|
+
${assetTagsSetup}
|
|
279
507
|
|
|
280
508
|
export async function createApp(options = {}) {
|
|
281
509
|
const app = createUbeanApp({
|
|
@@ -625,7 +853,8 @@ async function buildProduction(options) {
|
|
|
625
853
|
resolve: commonResolve
|
|
626
854
|
});
|
|
627
855
|
serverEntry = "entry.mjs";
|
|
628
|
-
if (
|
|
856
|
+
if (mode === "ssg") logger.info("SSG mode: skipping preset server wrapper (static output only)");
|
|
857
|
+
else if (presetBuildConfig.entryType === "node") {
|
|
629
858
|
await writeFile(join(outDirs.server, "server.mjs"), generateNodeServerEntry(), "utf-8");
|
|
630
859
|
serverEntry = "server.mjs";
|
|
631
860
|
await writeFile(join(outDirs.server, "package.json"), JSON.stringify({
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ScannedPageRoute } from "@ubean/scan";
|
|
2
|
+
//#region src/static-render.d.ts
|
|
3
|
+
/** 哨兵路由:prerender 队列中的 404 页标记,产物映射为 `404.html` */
|
|
4
|
+
declare const STATIC_NOT_FOUND_ROUTE = "/__ubean_404__";
|
|
5
|
+
interface CompiledPageRoute {
|
|
6
|
+
page: ScannedPageRoute;
|
|
7
|
+
regex: RegExp;
|
|
8
|
+
/** 捕获组顺序对应的参数名 */
|
|
9
|
+
paramNames: string[];
|
|
10
|
+
isCatchAll: boolean;
|
|
11
|
+
/** 静态段数量(特异性排序用) */
|
|
12
|
+
staticSegments: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 编译页面路由模式为正则。
|
|
16
|
+
*
|
|
17
|
+
* 输入 `page.route` 两种形态:
|
|
18
|
+
* 1. scan 产物默认语法(route-path.ts):
|
|
19
|
+
* - `/about`(静态)
|
|
20
|
+
* - `/blog/:slug`(动态)
|
|
21
|
+
* - `/docs/**:path`(catch-all,匹配剩余全部段,也匹配模式自身)
|
|
22
|
+
* - `/users/:id?`(可选段)
|
|
23
|
+
* 2. `definePage({ path })` 覆盖的 vue-router 语法:
|
|
24
|
+
* - `/blog/:slug(.*)*`(自定义正则 + 可重复 = catch-all)
|
|
25
|
+
* - `/users/:id(\\d+)`(自定义正则)
|
|
26
|
+
*/
|
|
27
|
+
declare function compilePageRoute(page: ScannedPageRoute): CompiledPageRoute;
|
|
28
|
+
interface StaticRouteMatch {
|
|
29
|
+
page: ScannedPageRoute;
|
|
30
|
+
params: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 将具象 URL path 匹配到页面路由模式,返回命中的页面与提取的参数。
|
|
34
|
+
*
|
|
35
|
+
* 匹配顺序(确定性):静态段多者优先 → catch-all 最后 → 同分保持传入顺序。
|
|
36
|
+
* 参数值经 `decodeURIComponent` 解码(对齐 Hono `c.req.param()` 行为)。
|
|
37
|
+
*/
|
|
38
|
+
declare function matchRoutePattern(pages: Array<ScannedPageRoute | CompiledPageRoute>, path: string): StaticRouteMatch | null;
|
|
39
|
+
/**
|
|
40
|
+
* 按路由策略将收集到的路由展开为全语言 URL 列表。
|
|
41
|
+
*
|
|
42
|
+
* | strategy | 展开规则 |
|
|
43
|
+
* | --- | --- |
|
|
44
|
+
* | `no_prefix` | 不展开(每 route 渲染 1 次) |
|
|
45
|
+
* | `prefix_except_default` | default 无前缀 + 其余 locale 带前缀 |
|
|
46
|
+
* | `prefix` | 全部 locale 带前缀(无前缀版本不生成——fetcher 对其 404) |
|
|
47
|
+
* | `prefix_and_default` | 全部 locale 带前缀 + default 额外无前缀版本 |
|
|
48
|
+
*
|
|
49
|
+
* 特殊输入:
|
|
50
|
+
* - 404 哨兵路由不展开(静态托管平台仅认单份 `404.html`)
|
|
51
|
+
* - 已带 locale 前缀的 URL(crawlLinks 发现)保持原样,避免 `/zh/zh/about`
|
|
52
|
+
*/
|
|
53
|
+
declare function expandRoutesForLocales(routes: string[], i18n: StaticSsgRendererI18nInit): string[];
|
|
54
|
+
interface StaticSsgRendererI18nInit {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
strategy?: 'prefix' | 'prefix_except_default' | 'prefix_and_default' | 'no_prefix';
|
|
57
|
+
defaultLocale?: string;
|
|
58
|
+
locales?: Array<string | {
|
|
59
|
+
code: string;
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
interface StaticSsgRendererInit {
|
|
63
|
+
/** 扫描得到的页面(含动态路由模式,供具象 URL 匹配) */
|
|
64
|
+
pages: ScannedPageRoute[];
|
|
65
|
+
/** `pages/404.vue` 自动检测的 404 页(可选) */
|
|
66
|
+
notFoundPage?: ScannedPageRoute | null;
|
|
67
|
+
/** i18n 配置(用于 locale 前缀剥离与默认语言) */
|
|
68
|
+
i18n?: StaticSsgRendererI18nInit;
|
|
69
|
+
}
|
|
70
|
+
interface RenderStaticPageInput {
|
|
71
|
+
pageName: string;
|
|
72
|
+
params: Record<string, string>;
|
|
73
|
+
url: string;
|
|
74
|
+
locale?: string;
|
|
75
|
+
}
|
|
76
|
+
type RenderStaticPageFn = (input: RenderStaticPageInput) => Promise<{
|
|
77
|
+
html: string;
|
|
78
|
+
statusCode: number;
|
|
79
|
+
}>;
|
|
80
|
+
interface StaticSsgRenderer {
|
|
81
|
+
/** 与 `PrerendererOptions.fetcher` 同构的契约 */
|
|
82
|
+
fetcher: (url: string) => Promise<{
|
|
83
|
+
html: string;
|
|
84
|
+
statusCode: number;
|
|
85
|
+
}>;
|
|
86
|
+
/** i18n 多语言展开(按 strategy 生成全语言 URL,供 prerender 入队前应用) */
|
|
87
|
+
expandRoutes: (routes: string[]) => string[];
|
|
88
|
+
/** 底层渲染函数(等价性测试 / 调试用) */
|
|
89
|
+
renderStaticPage: RenderStaticPageFn;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 加载构建产物中的静态 entry(`dist/server/entry.mjs`),返回静态渲染器。
|
|
93
|
+
*
|
|
94
|
+
* 失败(entry 缺失 / 无 renderStaticPage 导出 / 加载异常)返回 `undefined`,
|
|
95
|
+
* 由调用方降级(warn + placeholder prerender)。
|
|
96
|
+
*/
|
|
97
|
+
declare function createStaticSsgRenderer(cwd: string, manifest: {
|
|
98
|
+
serverDir: string;
|
|
99
|
+
}, init: StaticSsgRendererInit): Promise<StaticSsgRenderer | undefined>;
|
|
100
|
+
//#endregion
|
|
101
|
+
export { CompiledPageRoute, RenderStaticPageFn, RenderStaticPageInput, STATIC_NOT_FOUND_ROUTE, StaticRouteMatch, StaticSsgRenderer, StaticSsgRendererI18nInit, StaticSsgRendererInit, compilePageRoute, createStaticSsgRenderer, expandRoutesForLocales, matchRoutePattern };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { resolve } from "pathe";
|
|
2
|
+
import { extractLocaleFromPath } from "@ubean/i18n";
|
|
3
|
+
import { getLogger } from "@ubean/shared/logger";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
//#region src/static-render.ts
|
|
6
|
+
/**
|
|
7
|
+
* 静态 SSG 渲染器(mode: 'ssg' 专用,见 docs/adr/0011-lightweight-ssg-direct-render.md)。
|
|
8
|
+
*
|
|
9
|
+
* 职责:
|
|
10
|
+
* 1. `compilePageRoute` / `matchRoutePattern`:把具象 URL 匹配到页面路由模式
|
|
11
|
+
* (`[param]` → `:param`、`[...slug]` → `**:slug`、`[[param]]` → `:param?`),
|
|
12
|
+
* 提取路由参数 —— 替代 Hono 的路由匹配层
|
|
13
|
+
* 2. `createStaticSsgRenderer`:加载构建产物中的静态 entry(renderStaticPage),
|
|
14
|
+
* 暴露与 `prerender()` 的 fetcher 同构的契约,绕过 Hono 请求管道
|
|
15
|
+
*/
|
|
16
|
+
const logger = getLogger("build");
|
|
17
|
+
/** 哨兵路由:prerender 队列中的 404 页标记,产物映射为 `404.html` */
|
|
18
|
+
const STATIC_NOT_FOUND_ROUTE = "/__ubean_404__";
|
|
19
|
+
const CATCH_ALL_SEGMENT = /^\*\*:([A-Za-z_][\w-]*)$/;
|
|
20
|
+
/**
|
|
21
|
+
* vue-router 参数段语法(`definePage({ path })` 覆盖时的 route 形态):
|
|
22
|
+
* - `:param` → 单段
|
|
23
|
+
* - `:param?` → 可选单段
|
|
24
|
+
* - `:param(regex)` → 自定义正则(如 `:id(\\d+)`)
|
|
25
|
+
* - `:param(regex)*` / `:param*` → 可重复(0..n 段,如 `:slug(.*)*` catch-all)
|
|
26
|
+
* - `:param(regex)+` / `:param+` → 可重复(1..n 段)
|
|
27
|
+
*/
|
|
28
|
+
const DYNAMIC_SEGMENT = /^:([A-Za-z_][\w-]*)(\(.+\))?([*+?])?$/;
|
|
29
|
+
function escapeRegExp(str) {
|
|
30
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
}
|
|
32
|
+
/** 归一化 URL path:去尾部斜杠(根路由归一为空串便于匹配) */
|
|
33
|
+
function normalizeUrlPath(path) {
|
|
34
|
+
if (path === "/" || path === "") return "";
|
|
35
|
+
let p = path;
|
|
36
|
+
if (!p.startsWith("/")) p = `/${p}`;
|
|
37
|
+
while (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 编译页面路由模式为正则。
|
|
42
|
+
*
|
|
43
|
+
* 输入 `page.route` 两种形态:
|
|
44
|
+
* 1. scan 产物默认语法(route-path.ts):
|
|
45
|
+
* - `/about`(静态)
|
|
46
|
+
* - `/blog/:slug`(动态)
|
|
47
|
+
* - `/docs/**:path`(catch-all,匹配剩余全部段,也匹配模式自身)
|
|
48
|
+
* - `/users/:id?`(可选段)
|
|
49
|
+
* 2. `definePage({ path })` 覆盖的 vue-router 语法:
|
|
50
|
+
* - `/blog/:slug(.*)*`(自定义正则 + 可重复 = catch-all)
|
|
51
|
+
* - `/users/:id(\\d+)`(自定义正则)
|
|
52
|
+
*/
|
|
53
|
+
function compilePageRoute(page) {
|
|
54
|
+
const segments = normalizeUrlPath(page.route).split("/").filter(Boolean);
|
|
55
|
+
let source = "^";
|
|
56
|
+
const paramNames = [];
|
|
57
|
+
let isCatchAll = false;
|
|
58
|
+
let staticSegments = 0;
|
|
59
|
+
for (const seg of segments) {
|
|
60
|
+
const catchAll = CATCH_ALL_SEGMENT.exec(seg);
|
|
61
|
+
if (catchAll) {
|
|
62
|
+
paramNames.push(catchAll[1]);
|
|
63
|
+
isCatchAll = true;
|
|
64
|
+
source += "(?:/(.+))?";
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const dynamic = DYNAMIC_SEGMENT.exec(seg);
|
|
68
|
+
if (dynamic) {
|
|
69
|
+
const [, name, custom, modifier] = dynamic;
|
|
70
|
+
paramNames.push(name);
|
|
71
|
+
const inner = custom ? custom.slice(1, -1) : "[^/]+";
|
|
72
|
+
if (modifier === "*" || modifier === "+") {
|
|
73
|
+
isCatchAll = true;
|
|
74
|
+
source += modifier === "*" ? `(?:/(${inner}(?:/${inner})*))?` : `/(${inner}(?:/${inner})*)`;
|
|
75
|
+
} else if (modifier === "?") source += `(?:/(${inner}))?`;
|
|
76
|
+
else source += `/(${inner})`;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
staticSegments++;
|
|
80
|
+
source += `/${escapeRegExp(seg)}`;
|
|
81
|
+
}
|
|
82
|
+
source += "/?$";
|
|
83
|
+
return {
|
|
84
|
+
page,
|
|
85
|
+
regex: new RegExp(source),
|
|
86
|
+
paramNames,
|
|
87
|
+
isCatchAll,
|
|
88
|
+
staticSegments
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 将具象 URL path 匹配到页面路由模式,返回命中的页面与提取的参数。
|
|
93
|
+
*
|
|
94
|
+
* 匹配顺序(确定性):静态段多者优先 → catch-all 最后 → 同分保持传入顺序。
|
|
95
|
+
* 参数值经 `decodeURIComponent` 解码(对齐 Hono `c.req.param()` 行为)。
|
|
96
|
+
*/
|
|
97
|
+
function matchRoutePattern(pages, path) {
|
|
98
|
+
const ordered = pages.map((p) => "regex" in p ? p : compilePageRoute(p)).map((c, i) => ({
|
|
99
|
+
c,
|
|
100
|
+
i
|
|
101
|
+
})).sort((a, b) => Number(a.c.isCatchAll) - Number(b.c.isCatchAll) || b.c.staticSegments - a.c.staticSegments || a.i - b.i).map(({ c }) => c);
|
|
102
|
+
const target = normalizeUrlPath(path);
|
|
103
|
+
for (const c of ordered) {
|
|
104
|
+
const m = c.regex.exec(target);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const params = {};
|
|
107
|
+
c.paramNames.forEach((name, idx) => {
|
|
108
|
+
const raw = m[idx + 1];
|
|
109
|
+
if (raw === void 0) return;
|
|
110
|
+
try {
|
|
111
|
+
params[name] = decodeURIComponent(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
params[name] = raw;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
page: c.page,
|
|
118
|
+
params
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function dedupeRoutes(routes) {
|
|
124
|
+
return [...new Set(routes)];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 按路由策略将收集到的路由展开为全语言 URL 列表。
|
|
128
|
+
*
|
|
129
|
+
* | strategy | 展开规则 |
|
|
130
|
+
* | --- | --- |
|
|
131
|
+
* | `no_prefix` | 不展开(每 route 渲染 1 次) |
|
|
132
|
+
* | `prefix_except_default` | default 无前缀 + 其余 locale 带前缀 |
|
|
133
|
+
* | `prefix` | 全部 locale 带前缀(无前缀版本不生成——fetcher 对其 404) |
|
|
134
|
+
* | `prefix_and_default` | 全部 locale 带前缀 + default 额外无前缀版本 |
|
|
135
|
+
*
|
|
136
|
+
* 特殊输入:
|
|
137
|
+
* - 404 哨兵路由不展开(静态托管平台仅认单份 `404.html`)
|
|
138
|
+
* - 已带 locale 前缀的 URL(crawlLinks 发现)保持原样,避免 `/zh/zh/about`
|
|
139
|
+
*/
|
|
140
|
+
function expandRoutesForLocales(routes, i18n) {
|
|
141
|
+
const i18nEnabled = i18n != null && i18n.enabled !== false;
|
|
142
|
+
const localeCodes = i18nEnabled ? (i18n.locales || []).map((l) => typeof l === "string" ? l : l.code) : [];
|
|
143
|
+
if (!i18nEnabled || localeCodes.length === 0) return dedupeRoutes(routes);
|
|
144
|
+
const strategy = i18n.strategy || "prefix_except_default";
|
|
145
|
+
if (strategy === "no_prefix") return dedupeRoutes(routes);
|
|
146
|
+
const defaultLocale = i18n.defaultLocale || localeCodes[0] || "";
|
|
147
|
+
const expanded = [];
|
|
148
|
+
for (const route of routes) {
|
|
149
|
+
if (route === "/__ubean_404__") {
|
|
150
|
+
expanded.push(route);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const { locale } = extractLocaleFromPath(route, localeCodes);
|
|
154
|
+
if (locale) {
|
|
155
|
+
expanded.push(route);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const suffix = route === "/" || route === "" ? "" : route;
|
|
159
|
+
switch (strategy) {
|
|
160
|
+
case "prefix":
|
|
161
|
+
for (const code of localeCodes) expanded.push(`/${code}${suffix}`);
|
|
162
|
+
break;
|
|
163
|
+
case "prefix_and_default":
|
|
164
|
+
expanded.push(route || "/");
|
|
165
|
+
for (const code of localeCodes) expanded.push(`/${code}${suffix}`);
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
expanded.push(route || "/");
|
|
169
|
+
for (const code of localeCodes) {
|
|
170
|
+
if (code === defaultLocale) continue;
|
|
171
|
+
expanded.push(`/${code}${suffix}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return dedupeRoutes(expanded);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 加载构建产物中的静态 entry(`dist/server/entry.mjs`),返回静态渲染器。
|
|
179
|
+
*
|
|
180
|
+
* 失败(entry 缺失 / 无 renderStaticPage 导出 / 加载异常)返回 `undefined`,
|
|
181
|
+
* 由调用方降级(warn + placeholder prerender)。
|
|
182
|
+
*/
|
|
183
|
+
async function createStaticSsgRenderer(cwd, manifest, init) {
|
|
184
|
+
let mod;
|
|
185
|
+
try {
|
|
186
|
+
const entryPath = resolve(cwd, manifest.serverDir, "entry.mjs");
|
|
187
|
+
mod = await import(pathToFileURL(entryPath).href);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.warn(`Failed to load static SSG entry: ${err instanceof Error ? err.message : String(err)}. Falling back.`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (typeof mod.renderStaticPage !== "function") {
|
|
193
|
+
logger.warn("Static SSG entry does not export renderStaticPage; falling back.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const renderStaticPage = mod.renderStaticPage;
|
|
197
|
+
const compiled = init.pages.map(compilePageRoute);
|
|
198
|
+
const i18nEnabled = init.i18n != null && init.i18n.enabled !== false;
|
|
199
|
+
const localeCodes = i18nEnabled ? (init.i18n?.locales || []).map((l) => typeof l === "string" ? l : l.code) : [];
|
|
200
|
+
const strategy = i18nEnabled ? init.i18n?.strategy || "prefix_except_default" : "no_prefix";
|
|
201
|
+
const defaultLocale = i18nEnabled && init.i18n?.defaultLocale || "";
|
|
202
|
+
const usePrefix = i18nEnabled && localeCodes.length > 0 && strategy !== "no_prefix";
|
|
203
|
+
return {
|
|
204
|
+
renderStaticPage,
|
|
205
|
+
expandRoutes: (routes) => expandRoutesForLocales(routes, {
|
|
206
|
+
enabled: i18nEnabled,
|
|
207
|
+
strategy,
|
|
208
|
+
defaultLocale,
|
|
209
|
+
locales: localeCodes
|
|
210
|
+
}),
|
|
211
|
+
fetcher: async (url) => {
|
|
212
|
+
const u = new URL(url, "http://ubean.local");
|
|
213
|
+
const pathname = u.pathname;
|
|
214
|
+
const search = u.search;
|
|
215
|
+
if (pathname === "/__ubean_404__") {
|
|
216
|
+
if (!init.notFoundPage) return {
|
|
217
|
+
html: "",
|
|
218
|
+
statusCode: 404
|
|
219
|
+
};
|
|
220
|
+
return renderStaticPage({
|
|
221
|
+
pageName: "NotFound",
|
|
222
|
+
params: {},
|
|
223
|
+
url: `/404${search}`,
|
|
224
|
+
locale: defaultLocale
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
let pathToMatch = pathname;
|
|
228
|
+
let locale = defaultLocale;
|
|
229
|
+
if (usePrefix) {
|
|
230
|
+
const { locale: extracted, pathWithoutLocale } = extractLocaleFromPath(pathname, localeCodes);
|
|
231
|
+
if (extracted) {
|
|
232
|
+
locale = extracted;
|
|
233
|
+
pathToMatch = pathWithoutLocale;
|
|
234
|
+
} else if (strategy === "prefix") return {
|
|
235
|
+
html: "",
|
|
236
|
+
statusCode: 404
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const match = matchRoutePattern(compiled, pathToMatch);
|
|
240
|
+
if (!match) return {
|
|
241
|
+
html: "",
|
|
242
|
+
statusCode: 404
|
|
243
|
+
};
|
|
244
|
+
return renderStaticPage({
|
|
245
|
+
pageName: match.page.name,
|
|
246
|
+
params: match.params,
|
|
247
|
+
url: `${pathname}${search}`,
|
|
248
|
+
locale
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
export { STATIC_NOT_FOUND_ROUTE, compilePageRoute, createStaticSsgRenderer, expandRoutesForLocales, matchRoutePattern };
|
|
@@ -9,10 +9,10 @@ import { transformWithOxc } from "vite";
|
|
|
9
9
|
import Components from "unplugin-vue-components/vite";
|
|
10
10
|
import Markdown from "unplugin-vue-markdown/vite";
|
|
11
11
|
import AutoImport from "unplugin-auto-import/vite";
|
|
12
|
-
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
|
|
13
12
|
import { getColorModeScript, getPartyTownScript, resolveColorModeConfig, resolvePartyTownConfig } from "@ubean/client";
|
|
14
13
|
import { ubeanMdxPlugin } from "@ubean/markdown";
|
|
15
14
|
import { renderFaviconLink } from "@ubean/pages";
|
|
15
|
+
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
|
|
16
16
|
import { generatePagesModuleSource } from "@ubean/vue/vite";
|
|
17
17
|
//#region src/vue-virtual-modules.ts
|
|
18
18
|
/**
|
|
@@ -162,15 +162,17 @@ function _mergeAppConfig(base, ...configs) {
|
|
|
162
162
|
for (const cfg of [base, ...configs]) {
|
|
163
163
|
if (cfg?.router?.setup) setups.push(cfg.router.setup);
|
|
164
164
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
};
|
|
165
|
+
// scrollBehavior 为函数不可合并,后者覆盖(shared 之后定义的 client/server 生效)。
|
|
166
|
+
let scrollBehavior;
|
|
167
|
+
for (const cfg of [base, ...configs]) {
|
|
168
|
+
if (cfg?.router?.scrollBehavior !== undefined) scrollBehavior = cfg.router.scrollBehavior;
|
|
169
|
+
}
|
|
170
|
+
const router = {};
|
|
171
|
+
if (setups.length > 0) {
|
|
172
|
+
router.setup = setups.length === 1 ? setups[0] : (r) => { for (const s of setups) s(r); };
|
|
173
173
|
}
|
|
174
|
+
if (scrollBehavior !== undefined) router.scrollBehavior = scrollBehavior;
|
|
175
|
+
if (setups.length > 0 || scrollBehavior !== undefined) result.router = router;
|
|
174
176
|
return result;
|
|
175
177
|
}
|
|
176
178
|
|
|
@@ -233,7 +235,7 @@ export async function createApp() {
|
|
|
233
235
|
viewTransitions: config.viewTransitions,
|
|
234
236
|
initialPage: initialPage || undefined,
|
|
235
237
|
hydrate: !!initialPage,
|
|
236
|
-
|
|
238
|
+
router: config.router,
|
|
237
239
|
loadingComponent,
|
|
238
240
|
errorComponent,
|
|
239
241
|
// appRoot 已在 resolveAppConfig 中合并(src/App.vue 默认 + defineApp 显式覆盖)
|
|
@@ -318,7 +320,7 @@ export async function createSSRApp(initialPage) {
|
|
|
318
320
|
resolveLayoutComponent,
|
|
319
321
|
defaultLayout,
|
|
320
322
|
head,
|
|
321
|
-
|
|
323
|
+
router: config.router,
|
|
322
324
|
loadingComponent,
|
|
323
325
|
errorComponent,
|
|
324
326
|
// appRoot 已在 resolveAppConfig 中合并(src/App.vue 默认 + defineApp 显式覆盖),
|
package/dist/vue.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as createVueAppEntryVirtualModule, i as createServerEntryVirtualModule, n as ubeanVite, o as createVuePagesVirtualModule, r as createClientEntryVirtualModule, t as VUE_PLUGIN_INCLUDE } from "./vue-
|
|
1
|
+
import { a as createVueAppEntryVirtualModule, i as createServerEntryVirtualModule, n as ubeanVite, o as createVuePagesVirtualModule, r as createClientEntryVirtualModule, t as VUE_PLUGIN_INCLUDE } from "./vue-CIj7ErAs.js";
|
|
2
2
|
export { VUE_PLUGIN_INCLUDE, createClientEntryVirtualModule, createServerEntryVirtualModule, createVueAppEntryVirtualModule, createVuePagesVirtualModule, ubeanVite as default, ubeanVite };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ubean/build",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Vite plugins, production build, SSG prerender, type codegen, and Server Actions plugin for ubean",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"types": "./dist/prerender.d.ts",
|
|
31
31
|
"import": "./dist/prerender.js"
|
|
32
32
|
},
|
|
33
|
+
"./static-render": {
|
|
34
|
+
"types": "./dist/static-render.d.ts",
|
|
35
|
+
"import": "./dist/static-render.js"
|
|
36
|
+
},
|
|
33
37
|
"./codegen": {
|
|
34
38
|
"types": "./dist/codegen/index.d.ts",
|
|
35
39
|
"import": "./dist/codegen/index.js"
|
|
@@ -41,17 +45,17 @@
|
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@intlify/unplugin-vue-i18n": "11.2.5",
|
|
44
|
-
"@ubean/client": "0.4.
|
|
45
|
-
"@ubean/config": "0.4.
|
|
46
|
-
"@ubean/i18n": "0.4.
|
|
47
|
-
"@ubean/islands": "0.4.
|
|
48
|
-
"@ubean/markdown": "0.4.
|
|
49
|
-
"@ubean/pages": "0.4.
|
|
50
|
-
"@ubean/preset": "0.4.
|
|
51
|
-
"@ubean/routes": "0.4.
|
|
52
|
-
"@ubean/scan": "0.4.
|
|
53
|
-
"@ubean/shared": "0.4.
|
|
54
|
-
"@ubean/vue": "0.4.
|
|
48
|
+
"@ubean/client": "0.4.5",
|
|
49
|
+
"@ubean/config": "0.4.5",
|
|
50
|
+
"@ubean/i18n": "0.4.5",
|
|
51
|
+
"@ubean/islands": "0.4.5",
|
|
52
|
+
"@ubean/markdown": "0.4.5",
|
|
53
|
+
"@ubean/pages": "0.4.5",
|
|
54
|
+
"@ubean/preset": "0.4.5",
|
|
55
|
+
"@ubean/routes": "0.4.5",
|
|
56
|
+
"@ubean/scan": "0.4.5",
|
|
57
|
+
"@ubean/shared": "0.4.5",
|
|
58
|
+
"@ubean/vue": "0.4.5",
|
|
55
59
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
56
60
|
"openapi-typescript": "^7.13.0",
|
|
57
61
|
"pathe": "^2.0.3",
|