@ubean/build 0.2.2 → 0.3.1

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/index.d.ts CHANGED
@@ -1,11 +1,99 @@
1
- import { VirtualModule, VirtualModuleContext, VirtualModuleRegistry, VirtualModuleTransform, defineVirtualModule, defineVirtualModulePrefix, getComponentResolvers, getCssImports, registerComponentResolver, registerCssImport, resetModuleRegistry, resetVirtualRegistry, stripMacros, transformMacros, useVirtualRegistry } from "@ubean/build-core";
1
+ import { a as defineVirtualModule, c as useVirtualRegistry, i as VirtualModuleTransform, n as VirtualModuleContext, o as defineVirtualModulePrefix, r as VirtualModuleRegistry, s as resetVirtualRegistry, t as VirtualModule } from "./virtual-registry-BF0jYPle.js";
2
2
  import { CompiledLayout, CompiledMiddleware, CompiledPage, CompiledRoute } from "@ubean/routes";
3
3
  import { ScannedApiRoute, ScannedLocale, ScannedMiddleware, ScannedPageRoute } from "@ubean/scan";
4
+ //#region src/macros.d.ts
5
+ declare function stripMacros(code: string, macros?: readonly string[]): string;
6
+ declare function transformMacros(code: string, id: string): string | null;
7
+ //#endregion
8
+ //#region src/registry.d.ts
9
+ /**
10
+ * Module extension registry.
11
+ *
12
+ * Allows built-in extension modules (@ubean/integrations, @ubean/icon, etc.) to inject
13
+ * component resolvers and CSS imports into the core Vite plugin pipeline
14
+ * without modifying ubeanVite directly.
15
+ *
16
+ * Registration happens at module load time (when ubeanUiPlugin() etc. are
17
+ * called by the module system, before ubeanVite reads the registry).
18
+ *
19
+ * Uses globalThis for storage to ensure state is shared across multiple
20
+ * copies of @ubean/build that may exist due to package manager duplication
21
+ * (e.g. pnpm creating separate instances for different version ranges).
22
+ */
23
+ /**
24
+ * Generic component resolver type compatible with unplugin-vue-components.
25
+ * Accepts both function resolvers and object resolvers (ComponentResolverObject).
26
+ */
27
+ type ComponentResolver = ((name: string) => any) | {
28
+ type: 'component' | 'directive';
29
+ resolve: (name: string) => any;
30
+ };
31
+ /**
32
+ * Register a component resolver (e.g. `UiResolver()` from `@soybeanjs/ui/resolver`).
33
+ * The resolver will be merged into `unplugin-vue-components`'s `resolvers` array.
34
+ */
35
+ declare function registerComponentResolver(resolver: ComponentResolver): void;
36
+ /**
37
+ * Get all registered component resolvers.
38
+ * Called by `ubeanVite` when constructing the `Components()` plugin.
39
+ */
40
+ declare function getComponentResolvers(): ComponentResolver[];
41
+ /**
42
+ * Register a CSS import path to be injected into the client entry.
43
+ * e.g. `registerCssImport('@soybeanjs/ui/styles.css')` makes the client
44
+ * entry module prepend `import '@soybeanjs/ui/styles.css'`.
45
+ */
46
+ declare function registerCssImport(cssPath: string): void;
47
+ /**
48
+ * Get all registered CSS import paths.
49
+ * Called by `createClientEntryVirtualModule` to prepend CSS imports.
50
+ */
51
+ declare function getCssImports(): string[];
52
+ /**
53
+ * Reset the registry (for testing).
54
+ * @internal
55
+ */
56
+ declare function resetModuleRegistry(): void;
57
+ //#endregion
58
+ //#region src/ssr-singleton.d.ts
59
+ /**
60
+ * Single-copy runtime policy for Vite client / SSR graphs.
61
+ *
62
+ * `@ubean/i18n` (ALS + catalogs) and `@ubean/client` (locale runtime) must
63
+ * resolve to one module instance per process. Dev and production use the
64
+ * same package lists; only the SSR externalization differs:
65
+ *
66
+ * - Dev: Vite `ssrLoadModule` and the CLI-created app share Node's
67
+ * `@ubean/i18n` (external). `ubean` stays noExternal so virtual ids resolve.
68
+ * - Prod: `ubean` is bundled (`noExternal`). Do **not** also externalize
69
+ * `@ubean/i18n` — that would create a second copy beside the bundle.
70
+ */
71
+ declare const SSR_SINGLETON_PACKAGES: readonly ["ubean", "@ubean/client", "@ubean/i18n", "vue-i18n", "@intlify/core", "@intlify/core-base"];
72
+ declare const SSR_SINGLETON_DEDUPE: readonly ["vue", "vue-i18n", "@ubean/client", "@ubean/i18n"];
73
+ declare const SSR_SINGLETON_OPTIMIZE_EXCLUDE: readonly ["ubean", "@ubean/client", "@ubean/i18n"];
74
+ declare function ssrSingletonDevPolicy(): {
75
+ resolve: {
76
+ dedupe: string[];
77
+ };
78
+ optimizeDeps: {
79
+ exclude: string[];
80
+ include: string[];
81
+ };
82
+ ssr: {
83
+ noExternal: string[];
84
+ external: string[];
85
+ };
86
+ };
87
+ declare function ssrSingletonProdSsr(): {
88
+ noExternal: string[];
89
+ };
90
+ declare function ssrSingletonProdOptimizeExclude(extra?: string[]): string[];
91
+ //#endregion
4
92
  //#region src/virtual-modules.d.ts
5
- declare function createRoutingVirtualModule(routes: CompiledRoute[], middlewares: CompiledMiddleware[], cwd: string): any;
6
- declare function createPagesVirtualModule(pages: CompiledPage[], layouts: CompiledLayout[], cwd: string): any;
7
- declare function createMetaVirtualModule(): any;
8
- declare function createAppVirtualModule(apiRoutes: ScannedApiRoute[], middlewares: ScannedMiddleware[], pages: ScannedPageRoute[], srcDir: string): any;
9
- declare function createLocalesVirtualModule(_locales: ScannedLocale[], defaultLocale: string | undefined, srcDir?: string): any;
93
+ declare function createRoutingVirtualModule(routes: CompiledRoute[], middlewares: CompiledMiddleware[], cwd: string): VirtualModule;
94
+ declare function createPagesVirtualModule(pages: CompiledPage[], layouts: CompiledLayout[], cwd: string): VirtualModule;
95
+ declare function createMetaVirtualModule(): VirtualModule;
96
+ declare function createAppVirtualModule(apiRoutes: ScannedApiRoute[], middlewares: ScannedMiddleware[], pages: ScannedPageRoute[], srcDir: string): VirtualModule;
97
+ declare function createLocalesVirtualModule(_locales: ScannedLocale[], defaultLocale: string | undefined, srcDir?: string, i18nConfigJson?: string): VirtualModule;
10
98
  //#endregion
11
- export { type VirtualModule, type VirtualModuleContext, VirtualModuleRegistry, type VirtualModuleTransform, createAppVirtualModule, createLocalesVirtualModule, createMetaVirtualModule, createPagesVirtualModule, createRoutingVirtualModule, defineVirtualModule, defineVirtualModulePrefix, getComponentResolvers, getCssImports, registerComponentResolver, registerCssImport, resetModuleRegistry, resetVirtualRegistry, stripMacros, transformMacros, useVirtualRegistry };
99
+ export { SSR_SINGLETON_DEDUPE, SSR_SINGLETON_OPTIMIZE_EXCLUDE, SSR_SINGLETON_PACKAGES, type VirtualModule, type VirtualModuleContext, VirtualModuleRegistry, type VirtualModuleTransform, createAppVirtualModule, createLocalesVirtualModule, createMetaVirtualModule, createPagesVirtualModule, createRoutingVirtualModule, defineVirtualModule, defineVirtualModulePrefix, getComponentResolvers, getCssImports, registerComponentResolver, registerCssImport, resetModuleRegistry, resetVirtualRegistry, ssrSingletonDevPolicy, ssrSingletonProdOptimizeExclude, ssrSingletonProdSsr, stripMacros, transformMacros, useVirtualRegistry };
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
- import { a as createRoutingVirtualModule, i as createPagesVirtualModule, n as createLocalesVirtualModule, r as createMetaVirtualModule, t as createAppVirtualModule } from "./virtual-modules-BDgTqx3t.js";
2
- import { VirtualModuleRegistry, defineVirtualModule, defineVirtualModulePrefix, getComponentResolvers, getCssImports, registerComponentResolver, registerCssImport, resetModuleRegistry, resetVirtualRegistry, stripMacros, transformMacros, useVirtualRegistry } from "@ubean/build-core";
3
- export { VirtualModuleRegistry, createAppVirtualModule, createLocalesVirtualModule, createMetaVirtualModule, createPagesVirtualModule, createRoutingVirtualModule, defineVirtualModule, defineVirtualModulePrefix, getComponentResolvers, getCssImports, registerComponentResolver, registerCssImport, resetModuleRegistry, resetVirtualRegistry, stripMacros, transformMacros, useVirtualRegistry };
1
+ import { a as useVirtualRegistry, i as resetVirtualRegistry, n as defineVirtualModule, r as defineVirtualModulePrefix, t as VirtualModuleRegistry } from "./virtual-registry-BWkaQHXN.js";
2
+ import { a as createRoutingVirtualModule, i as createPagesVirtualModule, n as createLocalesVirtualModule, o as stripMacros, r as createMetaVirtualModule, s as transformMacros, t as createAppVirtualModule } from "./virtual-modules-D5WBA3u7.js";
3
+ import { a as ssrSingletonProdOptimizeExclude, c as getCssImports, d as resetModuleRegistry, i as ssrSingletonDevPolicy, l as registerComponentResolver, n as SSR_SINGLETON_OPTIMIZE_EXCLUDE, o as ssrSingletonProdSsr, r as SSR_SINGLETON_PACKAGES, s as getComponentResolvers, t as SSR_SINGLETON_DEDUPE, u as registerCssImport } from "./ssr-singleton-CEQi_H6-.js";
4
+ export { SSR_SINGLETON_DEDUPE, SSR_SINGLETON_OPTIMIZE_EXCLUDE, SSR_SINGLETON_PACKAGES, VirtualModuleRegistry, createAppVirtualModule, createLocalesVirtualModule, createMetaVirtualModule, createPagesVirtualModule, createRoutingVirtualModule, defineVirtualModule, defineVirtualModulePrefix, getComponentResolvers, getCssImports, registerComponentResolver, registerCssImport, resetModuleRegistry, resetVirtualRegistry, ssrSingletonDevPolicy, ssrSingletonProdOptimizeExclude, ssrSingletonProdSsr, stripMacros, transformMacros, useVirtualRegistry };
@@ -0,0 +1,129 @@
1
+ import { PrerenderConfig, PrerenderResult, RouteRule } from "@ubean/config";
2
+ import { ScannedPageRoute } from "@ubean/scan";
3
+ import { RouteRule as RouteRule$1 } from "@ubean/shared";
4
+ //#region src/prerender.d.ts
5
+ interface PrerendererOptions {
6
+ cwd: string;
7
+ outputDir: string;
8
+ pages: ScannedPageRoute[];
9
+ /**
10
+ * 预渲染配置。可直接传用户配置对象(由 `resolvePrerenderConfig` 解析默认值)。
11
+ * 未设置或 `enabled: false` 时直接返回空结果。
12
+ */
13
+ prerender?: PrerenderConfig;
14
+ /**
15
+ * 路由规则(P9-03)。用于自动发现标记了 `prerender: true` 的路由。
16
+ * 与 `PrerenderConfig.include` / `all` 合并:
17
+ * - `all: true` 时 routeRules 中的 prerender 标记被忽略(已包含全部)
18
+ * - 否则 prerender 标记的路由加入 include 列表
19
+ * 受 `PrerenderConfig.exclude` 过滤。
20
+ */
21
+ routeRules?: Record<string, RouteRule$1>;
22
+ /** Content collection URLs merged into `include` (see `extractContentPageRoutes`). */
23
+ contentRoutes?: string[];
24
+ fetcher?: (url: string) => Promise<{
25
+ html: string;
26
+ statusCode: number;
27
+ }>;
28
+ }
29
+ /**
30
+ * 从 routeRules 中提取标记了 `prerender: true` 或 `ppr: true` 的路由模式
31
+ * (P9-03 + P9-04)。
32
+ *
33
+ * P9-04:`ppr: true` 隐含 `prerender: true` —— PPR 路由的静态壳需要通过
34
+ * 预渲染生成(`server:defer` 组件在预渲染时仅渲染 fallback)。
35
+ *
36
+ * 返回的路径列表会进一步与 `pages` 候选池匹配(glob 模式)或直接加入(具体路径)。
37
+ * 动态路由的具象值(如 `/blog/hello-world`)可通过具体路径直接加入。
38
+ */
39
+ declare function extractPrerenderRoutesFromRules(routeRules: Record<string, RouteRule$1> | undefined): string[];
40
+ /**
41
+ * 收集需要预渲染的路由列表。
42
+ *
43
+ * 行为:
44
+ * 1. 扫描 `pages` 得到所有非动态页面作为候选池
45
+ * 2. 若 `options.all === true`:加入候选池所有路由(`include` 被忽略)
46
+ * 3. 否则遍历 `options.include` + `options.routeRules` 中 `prerender: true` 的模式:
47
+ * - 含通配符的模式 → 与候选池匹配
48
+ * - 不含通配符的具体路径 → 直接加入(用于动态路由的具象值)
49
+ * 4. 应用 `options.exclude` 过滤
50
+ *
51
+ * 返回的 `skipped` 为被 `exclude` 命中的路由集合。
52
+ */
53
+ declare function collectPrerenderRoutes(pages: ScannedPageRoute[], options?: {
54
+ all?: boolean;
55
+ include?: string[];
56
+ exclude?: string[];
57
+ /** P9-03: routeRules 中 `prerender: true` 的模式会合并到 include 列表 */
58
+ routeRules?: Record<string, RouteRule$1>;
59
+ /** Content collection URLs (`extractContentPageRoutes`) merged into include */
60
+ contentRoutes?: string[];
61
+ }): {
62
+ routes: string[];
63
+ skipped: string[];
64
+ };
65
+ declare function extractLinks(html: string, baseUrl?: string): string[];
66
+ /**
67
+ * 将路由路径映射到磁盘文件路径。
68
+ *
69
+ * 规则:
70
+ * - `/` 或空 → `outputDir/index.html`
71
+ * - 含文件扩展名(`.html`/`.txt`/`.xml`/`.json`/`.webmanifest`/`.svg`/`.ico` 等)
72
+ * 的路由 → 保留原文件名,例如 `/robots.txt` → `outputDir/robots.txt`(文件,不是目录)
73
+ * - 其他路由 → `outputDir/<route>/index.html`
74
+ *
75
+ * 处理带扩展名路由是为了避免 `/robots.txt/index.html`、`/sitemap.xml/index.html`
76
+ * 这种被错误转成目录的产物。
77
+ */
78
+ declare function routeToFilePath(route: string, outputDir: string): string;
79
+ /**
80
+ * 将路由路径映射到对应的 `__data.json` 文件路径。
81
+ *
82
+ * 规则(与 `routeToFilePath` 对应,但产物是 JSON payload 而非 HTML):
83
+ * - `/` → `outputDir/__data.json`
84
+ * - `/about` → `outputDir/about/__data.json`
85
+ * - `/dashboard/settings` → `outputDir/dashboard/settings/__data.json`
86
+ *
87
+ * 与 HTML 不同,这里不需要处理扩展名路由(`.html`/`.txt` 等)—— payload
88
+ * 仅对页面路由有意义,扩展名路由(如 `/robots.txt`)不参与 payload 提取。
89
+ */
90
+ declare function routeToDataFilePath(route: string, outputDir: string): string;
91
+ /**
92
+ * `extractDataPayload` 的返回结构。
93
+ */
94
+ interface ExtractedPayload {
95
+ /** 从 `__UBEAN_DATA__` 解析出的 payload 数据。 */
96
+ data: Record<string, unknown>;
97
+ /** 替换内联 script 后的 HTML(包含 preload link + 引导脚本)。 */
98
+ html: string;
99
+ /** `__data.json` 的 URL(用于 preload + fetch)。 */
100
+ dataUrl: string;
101
+ }
102
+ /**
103
+ * 从预渲染 HTML 中提取 `__UBEAN_DATA__` payload,替换为外部引用。
104
+ *
105
+ * 流程:
106
+ * 1. 用正则匹配 `<script id="__UBEAN_DATA__">JSON</script>`
107
+ * 2. JSON.parse 提取的数据(解析失败/为空 → 返回 null)
108
+ * 3. 计算 `dataUrl`(根路由 → `/__data.json`,其他 → `<route>/__data.json`)
109
+ * 4. 用 `<link rel="preload">` + 引导脚本替换内联 script
110
+ * (引导脚本通过 `fetch(dataUrl)` 加载 payload,设置全局 `__UBEAN_DATA_PAYLOAD__`)
111
+ *
112
+ * 返回 `null` 表示不进行提取(无 script / JSON 解析失败 / payload 为空对象)。
113
+ *
114
+ * @param html 预渲染后的完整 HTML
115
+ * @param route 当前路由路径(用于计算 dataUrl)
116
+ */
117
+ declare function extractDataPayload(html: string, route: string): ExtractedPayload | null;
118
+ declare function writePrerenderedFile(filePath: string, html: string): Promise<void>;
119
+ declare function prerender(options: PrerendererOptions): Promise<PrerenderResult>;
120
+ declare function generatePrerenderManifest(result: PrerenderResult, baseUrl?: string): {
121
+ routes: string[];
122
+ generatedAt: string;
123
+ errors: Array<{
124
+ route: string;
125
+ message: string;
126
+ }>;
127
+ };
128
+ //#endregion
129
+ export { ExtractedPayload, PrerendererOptions, type RouteRule, collectPrerenderRoutes, extractDataPayload, extractLinks, extractPrerenderRoutesFromRules, generatePrerenderManifest, prerender, routeToDataFilePath, routeToFilePath, writePrerenderedFile };
@@ -0,0 +1,308 @@
1
+ import { dirname, join } from "pathe";
2
+ import { resolvePrerenderConfig } from "@ubean/config";
3
+ import { DATA_PAYLOAD_ID } from "@ubean/pages";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { matchGlob } from "@ubean/shared";
6
+ //#region src/prerender.ts
7
+ const LINK_REGEX = /<a[^>]+href=["']([^"']+)["'][^>]*>/gi;
8
+ /**
9
+ * 匹配 `__UBEAN_DATA__` 内联 script 的正则。
10
+ *
11
+ * 预渲染时从中提取 JSON payload,替换为 `<link rel="preload">` + 引导脚本
12
+ * (引导脚本通过 fetch 加载 `__data.json`,设置全局 `__UBEAN_DATA_PAYLOAD__`)。
13
+ *
14
+ * 使用 `[\s\S]*?` 非贪婪匹配,避免多个 script 之间的过度匹配。
15
+ */
16
+ const DATA_PAYLOAD_REGEX = new RegExp(`<script id="${DATA_PAYLOAD_ID}" type="application/json">([\\s\\S]*?)<\/script>`);
17
+ function isDynamicPath(path) {
18
+ return /\[.*?\]/.test(path) || path.includes(":");
19
+ }
20
+ function normalizePagePath(filePath) {
21
+ let path = filePath.replace(/\.(vue|ts|js|tsx|jsx|md|mdx)$/, "");
22
+ if (path === "index" || path.endsWith("/index")) path = path.slice(0, -5);
23
+ path = path.replace(/\\/g, "/");
24
+ if (!path.startsWith("/")) path = `/${path}`;
25
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
26
+ return path || "/";
27
+ }
28
+ /**
29
+ * 从 routeRules 中提取标记了 `prerender: true` 或 `ppr: true` 的路由模式
30
+ * (P9-03 + P9-04)。
31
+ *
32
+ * P9-04:`ppr: true` 隐含 `prerender: true` —— PPR 路由的静态壳需要通过
33
+ * 预渲染生成(`server:defer` 组件在预渲染时仅渲染 fallback)。
34
+ *
35
+ * 返回的路径列表会进一步与 `pages` 候选池匹配(glob 模式)或直接加入(具体路径)。
36
+ * 动态路由的具象值(如 `/blog/hello-world`)可通过具体路径直接加入。
37
+ */
38
+ function extractPrerenderRoutesFromRules(routeRules) {
39
+ if (!routeRules) return [];
40
+ const result = [];
41
+ for (const [pattern, rule] of Object.entries(routeRules)) if (rule?.prerender === true || rule?.ppr === true) result.push(pattern);
42
+ return result;
43
+ }
44
+ /**
45
+ * 收集需要预渲染的路由列表。
46
+ *
47
+ * 行为:
48
+ * 1. 扫描 `pages` 得到所有非动态页面作为候选池
49
+ * 2. 若 `options.all === true`:加入候选池所有路由(`include` 被忽略)
50
+ * 3. 否则遍历 `options.include` + `options.routeRules` 中 `prerender: true` 的模式:
51
+ * - 含通配符的模式 → 与候选池匹配
52
+ * - 不含通配符的具体路径 → 直接加入(用于动态路由的具象值)
53
+ * 4. 应用 `options.exclude` 过滤
54
+ *
55
+ * 返回的 `skipped` 为被 `exclude` 命中的路由集合。
56
+ */
57
+ function collectPrerenderRoutes(pages, options = {}) {
58
+ const all = options.all === true;
59
+ const exclude = options.exclude ?? [];
60
+ const include = [
61
+ ...options.include ?? [],
62
+ ...extractPrerenderRoutesFromRules(options.routeRules),
63
+ ...options.contentRoutes ?? []
64
+ ];
65
+ const allPageRoutes = [];
66
+ for (const page of pages) {
67
+ const path = normalizePagePath(page.path);
68
+ if (!isDynamicPath(path)) allPageRoutes.push(path);
69
+ }
70
+ const matched = /* @__PURE__ */ new Set();
71
+ if (all) for (const r of allPageRoutes) matched.add(r);
72
+ else for (const pattern of include) if (pattern.includes("*")) {
73
+ for (const r of allPageRoutes) if (matchGlob(r, pattern)) matched.add(r);
74
+ } else matched.add(pattern.startsWith("/") ? pattern : `/${pattern}`);
75
+ const skipped = [];
76
+ for (const r of matched) if (exclude.some((p) => matchGlob(r, p))) skipped.push(r);
77
+ for (const r of skipped) matched.delete(r);
78
+ return {
79
+ routes: Array.from(matched),
80
+ skipped
81
+ };
82
+ }
83
+ function extractLinks(html, baseUrl = "") {
84
+ const links = /* @__PURE__ */ new Set();
85
+ let match;
86
+ const anchorRe = new RegExp(LINK_REGEX.source, LINK_REGEX.flags);
87
+ while ((match = anchorRe.exec(html)) !== null) {
88
+ const href = match[1];
89
+ if (isInternalLink(href, baseUrl)) {
90
+ const path = normalizeHref(href, baseUrl);
91
+ if (path) links.add(path);
92
+ }
93
+ }
94
+ return Array.from(links);
95
+ }
96
+ function isInternalLink(href, baseUrl) {
97
+ if (!href) return false;
98
+ if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) return false;
99
+ if (href.startsWith("http://") || href.startsWith("https://")) return baseUrl ? href.startsWith(baseUrl) : false;
100
+ if (href.startsWith("//")) return false;
101
+ return true;
102
+ }
103
+ function normalizeHref(href, baseUrl) {
104
+ let path = href;
105
+ if (baseUrl && path.startsWith(baseUrl)) path = path.slice(baseUrl.length);
106
+ const hashIdx = path.indexOf("#");
107
+ if (hashIdx !== -1) path = path.slice(0, hashIdx);
108
+ const queryIdx = path.indexOf("?");
109
+ if (queryIdx !== -1) path = path.slice(0, queryIdx);
110
+ if (path.includes(":")) return null;
111
+ if (path.includes("//")) return null;
112
+ if (!path.startsWith("/")) path = `/${path}`;
113
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
114
+ return path || "/";
115
+ }
116
+ /**
117
+ * 将路由路径映射到磁盘文件路径。
118
+ *
119
+ * 规则:
120
+ * - `/` 或空 → `outputDir/index.html`
121
+ * - 含文件扩展名(`.html`/`.txt`/`.xml`/`.json`/`.webmanifest`/`.svg`/`.ico` 等)
122
+ * 的路由 → 保留原文件名,例如 `/robots.txt` → `outputDir/robots.txt`(文件,不是目录)
123
+ * - 其他路由 → `outputDir/<route>/index.html`
124
+ *
125
+ * 处理带扩展名路由是为了避免 `/robots.txt/index.html`、`/sitemap.xml/index.html`
126
+ * 这种被错误转成目录的产物。
127
+ */
128
+ function routeToFilePath(route, outputDir) {
129
+ if (route === "/" || route === "") return join(outputDir, "index.html");
130
+ if (route.endsWith(".html")) return join(outputDir, route);
131
+ const lastSegment = route.slice(route.lastIndexOf("/") + 1);
132
+ const dotIdx = lastSegment.lastIndexOf(".");
133
+ if (dotIdx > 0 && dotIdx < lastSegment.length - 1) return join(outputDir, route);
134
+ return join(outputDir, route, "index.html");
135
+ }
136
+ /**
137
+ * 将路由路径映射到对应的 `__data.json` 文件路径。
138
+ *
139
+ * 规则(与 `routeToFilePath` 对应,但产物是 JSON payload 而非 HTML):
140
+ * - `/` → `outputDir/__data.json`
141
+ * - `/about` → `outputDir/about/__data.json`
142
+ * - `/dashboard/settings` → `outputDir/dashboard/settings/__data.json`
143
+ *
144
+ * 与 HTML 不同,这里不需要处理扩展名路由(`.html`/`.txt` 等)—— payload
145
+ * 仅对页面路由有意义,扩展名路由(如 `/robots.txt`)不参与 payload 提取。
146
+ */
147
+ function routeToDataFilePath(route, outputDir) {
148
+ if (route === "/" || route === "") return join(outputDir, "__data.json");
149
+ return join(outputDir, route, "__data.json");
150
+ }
151
+ /**
152
+ * 从预渲染 HTML 中提取 `__UBEAN_DATA__` payload,替换为外部引用。
153
+ *
154
+ * 流程:
155
+ * 1. 用正则匹配 `<script id="__UBEAN_DATA__">JSON<\/script>`
156
+ * 2. JSON.parse 提取的数据(解析失败/为空 → 返回 null)
157
+ * 3. 计算 `dataUrl`(根路由 → `/__data.json`,其他 → `<route>/__data.json`)
158
+ * 4. 用 `<link rel="preload">` + 引导脚本替换内联 script
159
+ * (引导脚本通过 `fetch(dataUrl)` 加载 payload,设置全局 `__UBEAN_DATA_PAYLOAD__`)
160
+ *
161
+ * 返回 `null` 表示不进行提取(无 script / JSON 解析失败 / payload 为空对象)。
162
+ *
163
+ * @param html 预渲染后的完整 HTML
164
+ * @param route 当前路由路径(用于计算 dataUrl)
165
+ */
166
+ function extractDataPayload(html, route) {
167
+ const match = DATA_PAYLOAD_REGEX.exec(html);
168
+ if (!match) return null;
169
+ const jsonText = match[1];
170
+ let data;
171
+ try {
172
+ data = JSON.parse(jsonText);
173
+ } catch {
174
+ return null;
175
+ }
176
+ if (!data || typeof data !== "object" || Object.keys(data).length === 0) return null;
177
+ const dataUrl = route === "/" || route === "" ? "/__data.json" : `${route}/__data.json`;
178
+ const replacement = `<link rel="preload" href="${dataUrl}" as="fetch" crossorigin="anonymous"><script>window.__UBEAN_DATA_PAYLOAD__=fetch(${JSON.stringify(dataUrl)},{credentials:"include"}).then(r=>r.ok?r.json():null).catch(()=>null)<\/script>`;
179
+ const modifiedHtml = html.slice(0, match.index) + replacement + html.slice(match.index + match[0].length);
180
+ return {
181
+ data,
182
+ html: modifiedHtml,
183
+ dataUrl
184
+ };
185
+ }
186
+ async function writePrerenderedFile(filePath, html) {
187
+ await mkdir(dirname(filePath), { recursive: true });
188
+ await writeFile(filePath, html, "utf-8");
189
+ }
190
+ const _logger = {
191
+ info: (msg) => console.log(msg),
192
+ warn: (msg) => console.warn(msg),
193
+ error: (msg) => console.error(msg),
194
+ success: (msg) => console.log(msg),
195
+ debug: (_msg) => {}
196
+ };
197
+ async function prerender(options) {
198
+ const startTime = Date.now();
199
+ const config = resolvePrerenderConfig(options.prerender);
200
+ const enabledByRules = extractPrerenderRoutesFromRules(options.routeRules).length > 0;
201
+ if (!config.enabled && !enabledByRules) return {
202
+ routes: [],
203
+ generated: [],
204
+ errors: [],
205
+ skipped: [],
206
+ duration: 0
207
+ };
208
+ const outputDir = join(options.cwd, config.staticDir);
209
+ const { routes: initialRoutes, skipped: initiallySkipped } = collectPrerenderRoutes(options.pages, {
210
+ all: config.all,
211
+ include: config.include,
212
+ exclude: config.exclude,
213
+ routeRules: options.routeRules,
214
+ contentRoutes: options.contentRoutes
215
+ });
216
+ const queue = [...initialRoutes];
217
+ const visited = /* @__PURE__ */ new Set();
218
+ const results = [];
219
+ const generated = [];
220
+ const errors = [];
221
+ const skipped = [...initiallySkipped];
222
+ _logger.info(`Prerendering ${queue.length} initial routes...`);
223
+ async function processRoute(route) {
224
+ if (visited.has(route)) return;
225
+ visited.add(route);
226
+ if (config.exclude.some((p) => matchGlob(route, p))) {
227
+ if (!skipped.includes(route)) skipped.push(route);
228
+ return;
229
+ }
230
+ const result = { route };
231
+ try {
232
+ if (options.fetcher) {
233
+ const resp = await options.fetcher(route);
234
+ result.statusCode = resp.statusCode;
235
+ result.html = resp.html;
236
+ if (resp.statusCode >= 400) {
237
+ result.error = /* @__PURE__ */ new Error(`HTTP ${resp.statusCode}`);
238
+ errors.push(result);
239
+ if (config.failOnError) throw result.error;
240
+ _logger.warn(` ⚠ ${route} → ${resp.statusCode}`);
241
+ return;
242
+ }
243
+ if (config.crawlLinks && resp.html) {
244
+ const links = extractLinks(resp.html);
245
+ for (const link of links) if (!visited.has(link) && !config.exclude.some((p) => matchGlob(link, p))) queue.push(link);
246
+ }
247
+ let htmlToWrite = resp.html;
248
+ if (config.extractDataPayload && resp.statusCode === 200 && resp.html) {
249
+ const extracted = extractDataPayload(resp.html, route);
250
+ if (extracted) {
251
+ await writePrerenderedFile(routeToDataFilePath(route, outputDir), JSON.stringify(extracted.data));
252
+ htmlToWrite = extracted.html;
253
+ }
254
+ }
255
+ await writePrerenderedFile(routeToFilePath(route, outputDir), htmlToWrite);
256
+ generated.push(route);
257
+ result.html = void 0;
258
+ } else {
259
+ const placeholderHtml = `<!DOCTYPE html>
260
+ <html>
261
+ <head><title>ubean</title></head>
262
+ <body>
263
+ <div id="app"><!-- Prerendered content placeholder for ${route} --></div>
264
+ </body>
265
+ </html>`;
266
+ await writePrerenderedFile(routeToFilePath(route, outputDir), placeholderHtml);
267
+ generated.push(route);
268
+ result.statusCode = 200;
269
+ }
270
+ } catch (err) {
271
+ result.error = err instanceof Error ? err : new Error(String(err));
272
+ errors.push(result);
273
+ if (config.failOnError) throw result.error;
274
+ _logger.warn(` ⚠ ${route} → ${result.error.message}`);
275
+ }
276
+ results.push(result);
277
+ }
278
+ while (queue.length > 0) {
279
+ const batch = [];
280
+ while (batch.length < config.concurrency && queue.length > 0) {
281
+ const r = queue.shift();
282
+ if (r && !visited.has(r)) batch.push(r);
283
+ }
284
+ if (batch.length === 0) break;
285
+ await Promise.all(batch.map((r) => processRoute(r)));
286
+ }
287
+ const duration = Date.now() - startTime;
288
+ _logger.success(`Prerendered ${generated.length} routes${errors.length > 0 ? ` (${errors.length} errors)` : ""}${skipped.length > 0 ? `, skipped ${skipped.length}` : ""} in ${duration}ms`);
289
+ return {
290
+ routes: results,
291
+ generated,
292
+ errors,
293
+ skipped,
294
+ duration
295
+ };
296
+ }
297
+ function generatePrerenderManifest(result, baseUrl = "/") {
298
+ return {
299
+ routes: result.generated.map((r) => baseUrl + r.replace(/^\//, "")),
300
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
301
+ errors: result.errors.map((e) => ({
302
+ route: e.route,
303
+ message: e.error?.message || "Unknown error"
304
+ }))
305
+ };
306
+ }
307
+ //#endregion
308
+ export { collectPrerenderRoutes, extractDataPayload, extractLinks, extractPrerenderRoutesFromRules, generatePrerenderManifest, prerender, routeToDataFilePath, routeToFilePath, writePrerenderedFile };
@@ -9,6 +9,8 @@ interface BuildOptions {
9
9
  scanResult: ScanResult;
10
10
  minify?: boolean;
11
11
  sourcemap?: boolean;
12
+ /** Inlined content collections for production SSR (`queryCollection`). */
13
+ contentSnapshot?: Record<string, unknown[]>;
12
14
  }
13
15
  interface BuildManifest {
14
16
  assets: Array<{