@withl5e/l5e 0.1.0-alpha.0

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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -0
  3. package/dist/action.js +10 -0
  4. package/dist/action.js.map +1 -0
  5. package/dist/client-D67hK4Yy.js +9 -0
  6. package/dist/client-D67hK4Yy.js.map +1 -0
  7. package/dist/entry-server-Ckh6zfgm.js +258 -0
  8. package/dist/entry-server-Ckh6zfgm.js.map +1 -0
  9. package/dist/entry-server.js +12 -0
  10. package/dist/entry-server.js.map +1 -0
  11. package/dist/generateMetadata-C5QsMS-H.js +144 -0
  12. package/dist/generateMetadata-C5QsMS-H.js.map +1 -0
  13. package/dist/index-BIt7MJT9.js +163 -0
  14. package/dist/index-BIt7MJT9.js.map +1 -0
  15. package/dist/index.js +49 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/island/client.js +5 -0
  18. package/dist/island/client.js.map +1 -0
  19. package/dist/island/runtime.js +98 -0
  20. package/dist/island/runtime.js.map +1 -0
  21. package/dist/island.js +39 -0
  22. package/dist/island.js.map +1 -0
  23. package/dist/jsx-runtime-C2Vw67N2.js +256 -0
  24. package/dist/jsx-runtime-C2Vw67N2.js.map +1 -0
  25. package/dist/jsx-runtime.js +26 -0
  26. package/dist/jsx-runtime.js.map +1 -0
  27. package/dist/middleware.js +9 -0
  28. package/dist/middleware.js.map +1 -0
  29. package/dist/seo.js +7 -0
  30. package/dist/seo.js.map +1 -0
  31. package/dist/server.js +489 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/swap/server.js +15 -0
  34. package/dist/swap/server.js.map +1 -0
  35. package/dist/swap.js +121 -0
  36. package/dist/swap.js.map +1 -0
  37. package/dist/tooltip.js +129 -0
  38. package/dist/tooltip.js.map +1 -0
  39. package/dist/vite-plugin.js +381 -0
  40. package/dist/vite-plugin.js.map +1 -0
  41. package/index.ts +1 -0
  42. package/package.json +129 -0
  43. package/src/action/define-action.ts +8 -0
  44. package/src/action/index.ts +2 -0
  45. package/src/action/types.ts +21 -0
  46. package/src/core/bundler.ts +275 -0
  47. package/src/core/const.ts +2 -0
  48. package/src/core/entry-server.d.ts +1 -0
  49. package/src/core/entry-server.ts +381 -0
  50. package/src/core/exceptions.ts +80 -0
  51. package/src/core/head-priority.ts +15 -0
  52. package/src/core/index.ts +40 -0
  53. package/src/core/jsx-runtime.ts +325 -0
  54. package/src/core/jsx-types.d.ts +548 -0
  55. package/src/core/render.ts +181 -0
  56. package/src/core/request.ts +31 -0
  57. package/src/core/server.ts +740 -0
  58. package/src/core/vite-plugin.ts +779 -0
  59. package/src/island/ClientIsland.ts +71 -0
  60. package/src/island/client.ts +3 -0
  61. package/src/island/index.ts +3 -0
  62. package/src/island/runtime.ts +149 -0
  63. package/src/island/strategy-registry.ts +10 -0
  64. package/src/island/types.ts +28 -0
  65. package/src/middleware/defineMiddleware.ts +5 -0
  66. package/src/middleware/index.ts +133 -0
  67. package/src/middleware/sequence.ts +105 -0
  68. package/src/middleware/types.ts +28 -0
  69. package/src/seo/generateMetadata.tsx +559 -0
  70. package/src/seo/index.ts +10 -0
  71. package/src/seo/mergeMetadata.ts +200 -0
  72. package/src/seo/types.ts +316 -0
  73. package/src/swap/SwapResponse.tsx +16 -0
  74. package/src/swap/create-swap.ts +121 -0
  75. package/src/swap/index.ts +8 -0
  76. package/src/swap/parse.ts +12 -0
  77. package/src/swap/server.ts +1 -0
  78. package/src/swap/swap.ts +57 -0
  79. package/src/swap/types.ts +47 -0
  80. package/src/swap/utils.ts +7 -0
  81. package/src/tooltip/index.ts +2 -0
  82. package/src/tooltip/tooltip-loader.ts +108 -0
  83. package/src/tooltip/tooltip-runtime.ts +173 -0
  84. package/types.d.ts +14 -0
@@ -0,0 +1,275 @@
1
+ /// <reference path="./jsx-types.d.ts" />
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { rollup, type OutputOptions, type RollupOptions } from 'rollup';
6
+
7
+ interface BundledFile {
8
+ content: string;
9
+ hash: string;
10
+ filename: string;
11
+ mimeType: string;
12
+ }
13
+
14
+ // Memory map để lưu bundled files
15
+ const bundledFilesMap = new Map<string, BundledFile>();
16
+
17
+ // Cache map để deduplicate bundling requests (cacheKey → entry chunk fileName)
18
+ const bundleCache = new Map<string, string>();
19
+ const cssCache = new Map<string, string>();
20
+
21
+ /**
22
+ * Generate hash từ content
23
+ */
24
+ function generateHash(content: string): string {
25
+ return createHash('sha256').update(content).digest('hex').substring(0, 16);
26
+ }
27
+
28
+ /**
29
+ * Bundle JavaScript files từ dist/client thành 1 file
30
+ * Trong production, các file đã được build sẵn trong dist/client
31
+ */
32
+ export async function bundleScripts(
33
+ scriptPaths: string[],
34
+ rootDir: string,
35
+ distClientDir: string,
36
+ ): Promise<{ hash: string; filename: string; content: string }> {
37
+ if (scriptPaths.length === 0) {
38
+ return { hash: '', filename: '', content: '' };
39
+ }
40
+
41
+ // Dedupe paths (remove duplicates)
42
+ const uniquePaths = [...new Set(scriptPaths)];
43
+
44
+ // Tạo cache key từ sorted unique paths
45
+ const cacheKey = `scripts:${uniquePaths.sort().join(',')}`;
46
+
47
+ // Kiểm tra cache - return entry chunk info if already bundled
48
+ const cachedEntryFileName = bundleCache.get(cacheKey);
49
+ if (cachedEntryFileName) {
50
+ const entryFile = bundledFilesMap.get(cachedEntryFileName);
51
+ if (entryFile) {
52
+ return {
53
+ hash: entryFile.hash,
54
+ filename: entryFile.filename,
55
+ content: entryFile.content,
56
+ };
57
+ }
58
+ }
59
+
60
+ // Temp file path for cleanup
61
+ let entryFile: string | null = null;
62
+
63
+ try {
64
+ // Sử dụng rollup để bundle nếu cần (resolve imports, etc)
65
+ // Tạo temp entry file
66
+ const hash = generateHash(uniquePaths.join('\n'));
67
+ const tempDir = path.join(rootDir, '.temp-bundle');
68
+ await fs.mkdir(tempDir, { recursive: true }).catch(() => {});
69
+
70
+ entryFile = path.join(tempDir, `entry-${hash}.js`);
71
+ // Tạo entry file import tất cả scripts
72
+ const entryContent = uniquePaths
73
+ .map((p, i) => {
74
+ const filePath = p.startsWith('/')
75
+ ? path.join(distClientDir, p.substring(1))
76
+ : path.join(distClientDir, p);
77
+ return `import ${JSON.stringify(filePath)};`;
78
+ })
79
+ .join('\n');
80
+
81
+ await fs.writeFile(entryFile, entryContent, 'utf-8');
82
+ console.log(`[bundler] Wrote entry file to ${entryFile}`);
83
+ console.log(`[bundler] Entry content: ${entryContent}`);
84
+ // Rollup config để bundle
85
+ const rollupOptions: RollupOptions = {
86
+ input: entryFile,
87
+ plugins: [
88
+ {
89
+ name: 'vendor-path-rewriter',
90
+ resolveId(source, importer, _options) {
91
+ // Handle vendor/chunk/global files: convert absolute paths to web paths
92
+ // Global files (*.global.*) are already loaded by client.global.ts —
93
+ // re-bundling them would create duplicate module instances (e.g. nanostores)
94
+ if (
95
+ source.includes('vendor-') ||
96
+ source.includes('chunk-') ||
97
+ source.includes('.global')
98
+ ) {
99
+ console.log(`[bundler] Resolving source: ${source}`);
100
+ if (path.isAbsolute(source)) {
101
+ console.log(`[bundler] Resolving absolute path: ${source}`);
102
+ // e.g., C:\...\dist\client\assets\vendor-react-XXX.js -> /assets/vendor-react-XXX.js
103
+ const relativePath = path.relative(distClientDir, source);
104
+ const webPath = '/' + relativePath.replace(/\\/g, '/');
105
+ return { id: webPath, external: true };
106
+ } else if (importer && source.startsWith('.')) {
107
+ console.log(
108
+ `[bundler] Resolving relative path: ${source} from importer: ${importer}`,
109
+ );
110
+ // Relative path like ./auth.global-BOVr81Z5.js — resolve from importer
111
+ const resolved = path.resolve(path.dirname(importer), source);
112
+ const relativePath = path.relative(distClientDir, resolved);
113
+ const webPath = '/' + relativePath.replace(/\\/g, '/');
114
+ return { id: webPath, external: true };
115
+ } else {
116
+ console.log(`[bundler] Resolving source: ${source}`);
117
+ }
118
+ }
119
+ return null; // Let other plugins/external handle
120
+ },
121
+ },
122
+ ],
123
+ external: (id) => {
124
+ // External node_modules
125
+ if (!id.startsWith('.') && !path.isAbsolute(id)) {
126
+ return true;
127
+ }
128
+
129
+ // Let plugin handle vendor/chunk/global files (don't mark external here)
130
+ if (id.includes('vendor-') || id.includes('chunk-') || id.includes('.global')) {
131
+ return false; // Let plugin's resolveId handle path rewriting
132
+ }
133
+
134
+ return false;
135
+ },
136
+ };
137
+
138
+ const outputOptions: OutputOptions = {
139
+ format: 'es',
140
+ inlineDynamicImports: false,
141
+ entryFileNames: 'bundle-[hash].js',
142
+ chunkFileNames: 'bundle-[hash].js',
143
+ };
144
+
145
+ const bundle = await rollup(rollupOptions);
146
+ const { output } = await bundle.generate(outputOptions);
147
+ await bundle.close();
148
+
149
+ // Lấy bundled content từ rollup
150
+
151
+ output.forEach((o) => {
152
+ if (o.type !== 'chunk') {
153
+ return;
154
+ }
155
+ // Lưu vào map với key = fileName
156
+ const bundledFile: BundledFile = {
157
+ content: o.code || '',
158
+ hash: generateHash(o.code || ''),
159
+ filename: o.fileName,
160
+ mimeType: 'application/javascript',
161
+ };
162
+ bundledFilesMap.set(o.fileName, bundledFile);
163
+ });
164
+
165
+ // Cache entry chunk fileName for deduplication
166
+ const entryChunk = output[0];
167
+ if (entryChunk?.type === 'chunk') {
168
+ bundleCache.set(cacheKey, entryChunk.fileName);
169
+ }
170
+
171
+ // Return entry chunk info
172
+ return {
173
+ hash: generateHash(output[0]?.code || ''),
174
+ filename: output[0]?.fileName || '',
175
+ content: output[0]?.code || '',
176
+ };
177
+ } catch (error) {
178
+ console.error('[bundler] Error bundling scripts:', error);
179
+ return { hash: '', filename: '', content: '' };
180
+ } finally {
181
+ // Cleanup temp entry file
182
+ if (entryFile) {
183
+ await fs.unlink(entryFile).catch(() => {
184
+ // Ignore cleanup errors
185
+ });
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Bundle CSS files từ dist/client thành 1 file
192
+ * Trong production, các file đã được build sẵn trong dist/client
193
+ */
194
+ export async function bundleCss(
195
+ cssPaths: string[],
196
+ rootDir: string,
197
+ distClientDir: string,
198
+ ): Promise<{ hash: string; filename: string; content: string }> {
199
+ if (cssPaths.length === 0) {
200
+ return { hash: '', filename: '', content: '' };
201
+ }
202
+
203
+ // Dedupe paths (remove duplicates)
204
+ const uniquePaths = [...new Set(cssPaths)];
205
+
206
+ // Tạo cache key từ sorted unique paths
207
+ const cacheKey = `css:${uniquePaths.sort().join(',')}`;
208
+
209
+ // Kiểm tra cache - return cached file if already bundled
210
+ const cachedFileName = cssCache.get(cacheKey);
211
+ if (cachedFileName) {
212
+ const cachedFile = bundledFilesMap.get(cachedFileName);
213
+ if (cachedFile) {
214
+ return {
215
+ hash: cachedFile.hash,
216
+ filename: cachedFile.filename,
217
+ content: cachedFile.content,
218
+ };
219
+ }
220
+ }
221
+
222
+ try {
223
+ // Đọc và gộp tất cả CSS files từ dist/client
224
+ const cssContents: string[] = [];
225
+
226
+ for (const cssPath of uniquePaths) {
227
+ // cssPath có thể là "/assets/xxx.css" hoặc từ manifest
228
+ const filePath = cssPath.startsWith('/')
229
+ ? path.join(distClientDir, cssPath.substring(1))
230
+ : path.join(distClientDir, cssPath);
231
+
232
+ try {
233
+ const content = await fs.readFile(filePath, 'utf-8');
234
+ cssContents.push(`/* ${cssPath} */\n${content}\n`);
235
+ } catch (err) {
236
+ console.warn(`[bundler] Failed to read CSS file: ${cssPath}`, err);
237
+ }
238
+ }
239
+
240
+ const bundledContent = cssContents.join('\n\n');
241
+ const hash = generateHash(bundledContent);
242
+ const filename = `bundle-${hash}.css`;
243
+
244
+ // Lưu vào map với key = filename
245
+ const bundledFile: BundledFile = {
246
+ content: bundledContent,
247
+ hash,
248
+ filename,
249
+ mimeType: 'text/css',
250
+ };
251
+ bundledFilesMap.set(filename, bundledFile);
252
+
253
+ // Cache filename for deduplication
254
+ cssCache.set(cacheKey, filename);
255
+
256
+ return { hash, filename, content: bundledContent };
257
+ } catch (error) {
258
+ console.error('[bundler] Error bundling CSS:', error);
259
+ return { hash: '', filename: '', content: '' };
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Get bundled file từ map
265
+ */
266
+ export function getBundledFile(filename: string): BundledFile | undefined {
267
+ return bundledFilesMap.get(filename);
268
+ }
269
+
270
+ /**
271
+ * Clear bundled files map (useful for testing)
272
+ */
273
+ export function clearBundledFiles(): void {
274
+ bundledFilesMap.clear();
275
+ }
@@ -0,0 +1,2 @@
1
+ // Marker object để đánh dấu raw HTML không escape
2
+ export const RAW_HTML_MARKER = Symbol('rawHtml');
@@ -0,0 +1 @@
1
+ export * from './entry-server';
@@ -0,0 +1,381 @@
1
+ /// <reference path="./jsx-types.d.ts" />
2
+ import { MetadataRenderer } from '../seo/generateMetadata';
3
+ import type { Metadata } from '../seo/types';
4
+ import {
5
+ HttpException,
6
+ InternalServerErrorException,
7
+ NotFoundException,
8
+ RedirectException,
9
+ ServiceUnavailableException,
10
+ } from './exceptions';
11
+ import { HEAD_PRIORITY } from './head-priority';
12
+ import {
13
+ addCacheTag,
14
+ getCacheTags,
15
+ getClientJsEntries,
16
+ getCssEntries,
17
+ getHeadContent,
18
+ getIslandEntries,
19
+ getSchemas,
20
+ jsxFactory as h,
21
+ Head,
22
+ pushMetadata,
23
+ pushSchema,
24
+ runInRenderContext,
25
+ setViewName,
26
+ } from './jsx-runtime';
27
+ import { renderJsxToHtmlString } from './render';
28
+ // @ts-ignore - Virtual modules provided by Vite plugin
29
+ import { viewComponents, viewLoaders } from 'virtual:l5e-views';
30
+ // @ts-ignore - Virtual modules provided by Vite plugin
31
+ import routeHandler from 'virtual:l5e-route';
32
+ // @ts-ignore - Virtual modules provided by Vite plugin
33
+ import { globalLoader } from 'virtual:l5e-global-loader';
34
+ // @ts-ignore - Virtual modules provided by Vite plugin
35
+ export { loadMiddleware } from 'virtual:l5e-middleware';
36
+
37
+ export interface RawResponse {
38
+ body: string | Buffer;
39
+ contentType: string;
40
+ statusCode?: number;
41
+ headers?: Record<string, string>;
42
+ }
43
+
44
+ export interface RenderResult {
45
+ html?: string;
46
+ scripts?: string[];
47
+ styles?: string[];
48
+ islands?: Array<{ key: string; src: string; name: string }>;
49
+ head?: string;
50
+ lang?: string;
51
+ statusCode?: number;
52
+ maxAge?: number;
53
+ sMaxAge?: number;
54
+ swr?: number;
55
+ cacheTags?: string[];
56
+ redirect?: { url: string; statusCode: number };
57
+ rawResponse?: RawResponse;
58
+ rawHtml?: boolean;
59
+ }
60
+
61
+ export interface RequestInfo {
62
+ url?: URL;
63
+ path?: string;
64
+ pathname?: string;
65
+ method?: string;
66
+ headers?: Record<string, any>;
67
+ cookies?: Record<string, string>;
68
+ query?: Record<string, any>;
69
+ ip?: string;
70
+ locals?: Record<string, unknown>;
71
+ }
72
+
73
+ // SchemaMarkup type - có thể là single schema hoặc array of schemas
74
+ // Sử dụng any để tương thích với schema-dts types từ frontend
75
+ export type SchemaMarkup = any | Array<any>;
76
+
77
+ export interface LoaderResult {
78
+ props?: Record<string, any>;
79
+ lang?: string;
80
+ maxAge?: number;
81
+ sMaxAge?: number;
82
+ swr?: number;
83
+ cacheTags?: string[] | Record<string, boolean>;
84
+ rawResponse?: RawResponse;
85
+ rawHtml?: boolean;
86
+ }
87
+
88
+ export type LoaderFunction = (requestInfo: RequestInfo) => Promise<LoaderResult>;
89
+
90
+ export type GenerateMetadataFunction = (requestInfo: RequestInfo, props: any) => Metadata | null;
91
+
92
+ export type GenerateSchemaFunction = (requestInfo: RequestInfo, props: any) => SchemaMarkup | null;
93
+
94
+ export interface GlobalLoaderModule {
95
+ loader: LoaderFunction;
96
+ generateMetadata?: GenerateMetadataFunction;
97
+ generateSchema?: GenerateSchemaFunction;
98
+ shouldIgnore?: (viewName: string) => boolean;
99
+ }
100
+
101
+ /**
102
+ * Helper function to render error view
103
+ */
104
+ async function renderErrorView(err: HttpException, lang?: string): Promise<RenderResult> {
105
+ const errorViewName = `_error`;
106
+ const errorComponentPath = `/src/views/${errorViewName}/index.tsx`;
107
+
108
+ // Set error view name in context
109
+ setViewName(errorViewName);
110
+
111
+ // Try to load error view
112
+ const errorComponentModule = viewComponents[errorComponentPath]
113
+ ? await viewComponents[errorComponentPath]()
114
+ : null;
115
+
116
+ if (errorComponentModule?.default) {
117
+ // Render error view with exception data
118
+ const errorProps = {
119
+ statusCode: err.statusCode,
120
+ message: err.message,
121
+ data: err.data,
122
+ };
123
+
124
+ const Component = errorComponentModule.default;
125
+ const htmlBody = renderJsxToHtmlString(h(Component, errorProps));
126
+ const clientEntries = getClientJsEntries();
127
+ const cssEntries = getCssEntries();
128
+ const headContent = getHeadContent();
129
+
130
+ const headHtml =
131
+ headContent.length > 0
132
+ ? headContent.map((content) => renderJsxToHtmlString(content)).join('\n ')
133
+ : undefined;
134
+
135
+ return {
136
+ html: htmlBody,
137
+ scripts: clientEntries.map((entry) => entry.path),
138
+ styles: cssEntries.map((entry) => entry.path),
139
+ head: headHtml,
140
+ lang,
141
+ statusCode: err.statusCode,
142
+ };
143
+ } else {
144
+ // No error view found, render default error message
145
+ const html = h('div', {}, `${err.statusCode} - ${err.message}`);
146
+ return {
147
+ html: renderJsxToHtmlString(html),
148
+ statusCode: err.statusCode,
149
+ };
150
+ }
151
+ }
152
+
153
+ export async function render(url: string, requestInfo: RequestInfo = {}): Promise<RenderResult> {
154
+ return runInRenderContext(async () => {
155
+ try {
156
+ // Step 1: Call route handler to get view name
157
+ const viewName = await routeHandler(requestInfo);
158
+
159
+ if (!viewName) {
160
+ // Throw NotFoundException to render error_404 view
161
+ throw new NotFoundException('Page not found', {
162
+ path: requestInfo.path,
163
+ pathname: requestInfo.pathname,
164
+ url: requestInfo.url?.href,
165
+ });
166
+ }
167
+
168
+ // Set view name in render context
169
+ setViewName(viewName);
170
+
171
+ // Step 2: Load global loader (optional)
172
+ let globalProps: Record<string, any> = {};
173
+ let lang: string | undefined;
174
+
175
+ // Try to load global loader
176
+ const globalLoaderPathTs = '/src/global-loader.ts';
177
+
178
+ const globalLoaderModule: GlobalLoaderModule | null = globalLoader[globalLoaderPathTs]
179
+ ? await globalLoader[globalLoaderPathTs]()
180
+ : null;
181
+
182
+ // Run global loader if exists and not ignored
183
+ if (globalLoaderModule?.loader) {
184
+ const shouldIgnore = globalLoaderModule.shouldIgnore?.(viewName) || false;
185
+
186
+ if (!shouldIgnore) {
187
+ const globalLoaderResult = await globalLoaderModule.loader(requestInfo);
188
+
189
+ globalProps = globalLoaderResult.props || {};
190
+ lang = globalLoaderResult.lang; // Extract lang from global loader
191
+
192
+ if (globalLoaderResult.cacheTags) {
193
+ addCacheTag(globalLoaderResult.cacheTags);
194
+ }
195
+
196
+ // generateMetadata và generateSchema sẽ được gọi sau khi có props
197
+ } else {
198
+ console.info(`Global loader ignored for view: ${viewName}`);
199
+ }
200
+ }
201
+
202
+ // Step 3: Dynamic import view loader (optional)
203
+ let viewProps: Record<string, any> = {};
204
+ let maxAge: number | undefined;
205
+ let sMaxAge: number | undefined;
206
+ let swr: number | undefined;
207
+ let rawHtml: boolean = false;
208
+ const loaderPathTs = `/src/views/${viewName}/loader.ts`;
209
+
210
+ // Try TypeScript loader formats only
211
+ const loaderModule = viewLoaders[loaderPathTs] ? await viewLoaders[loaderPathTs]() : null;
212
+
213
+ if (loaderModule?.loader) {
214
+ const loaderResult = await loaderModule.loader(requestInfo);
215
+
216
+ // Check if loader returns raw response
217
+ if (loaderResult.rawResponse) {
218
+ return {
219
+ rawResponse: loaderResult.rawResponse,
220
+ statusCode: loaderResult.rawResponse.statusCode || 200,
221
+ };
222
+ }
223
+
224
+ viewProps = loaderResult.props || {};
225
+ maxAge = loaderResult.maxAge;
226
+ sMaxAge = loaderResult.sMaxAge;
227
+ swr = loaderResult.swr;
228
+ rawHtml = loaderResult.rawHtml || false;
229
+
230
+ if (loaderResult.cacheTags) {
231
+ addCacheTag(loaderResult.cacheTags);
232
+ }
233
+
234
+ // View loader lang overrides global loader lang
235
+ if (loaderResult.lang) {
236
+ lang = loaderResult.lang;
237
+ }
238
+ } else {
239
+ console.info(`No loader for view: ${viewName}`);
240
+ }
241
+
242
+ // Merge props: global props first, then view props (view can override)
243
+ const props = { ...globalProps, ...viewProps };
244
+
245
+ // Step 3.5: Generate metadata và schema từ generateMetadata và generateSchema functions
246
+ // Global generateMetadata (parent metadata)
247
+ if (globalLoaderModule?.generateMetadata) {
248
+ const globalMetadata = globalLoaderModule.generateMetadata(requestInfo, props);
249
+ if (globalMetadata) {
250
+ pushMetadata(globalMetadata);
251
+ }
252
+ }
253
+
254
+ // View generateMetadata (child metadata, sẽ merge với parent)
255
+ if (loaderModule?.generateMetadata) {
256
+ const viewMetadata = loaderModule.generateMetadata(requestInfo, props);
257
+ if (viewMetadata) {
258
+ pushMetadata(viewMetadata);
259
+ }
260
+ }
261
+
262
+ // Global generateSchema (base schemas)
263
+ if (globalLoaderModule?.generateSchema) {
264
+ const globalSchema = globalLoaderModule.generateSchema(requestInfo, props);
265
+ if (globalSchema) {
266
+ pushSchema(globalSchema);
267
+ }
268
+ }
269
+
270
+ // View generateSchema (view-specific schemas)
271
+ if (loaderModule?.generateSchema) {
272
+ const viewSchema = loaderModule.generateSchema(requestInfo, props);
273
+ if (viewSchema) {
274
+ pushSchema(viewSchema);
275
+ }
276
+ }
277
+
278
+ // Step 4: Dynamic import component (required)
279
+ const componentPathTsx = `/src/views/${viewName}/index.tsx`;
280
+
281
+ const componentModule = viewComponents[componentPathTsx]
282
+ ? await viewComponents[componentPathTsx]()
283
+ : null;
284
+
285
+ const Component = componentModule?.default;
286
+
287
+ if (!Component) {
288
+ console.error(`View component not found: ${viewName}`);
289
+
290
+ // Check if in development or production mode
291
+ const isDevelopment = process.env.NODE_ENV !== 'production';
292
+
293
+ if (isDevelopment) {
294
+ // Development: provide detailed error information
295
+ throw new InternalServerErrorException(`View component not found: "${viewName}"`, {
296
+ viewName,
297
+ expectedPath: componentPathTsx,
298
+ availableViews: Object.keys(viewComponents),
299
+ hint: `Make sure the view component exists at ${componentPathTsx} and exports a default component`,
300
+ timestamp: new Date().toISOString(),
301
+ });
302
+ } else {
303
+ // Production: simple error message
304
+ throw new InternalServerErrorException('Internal Server Error');
305
+ }
306
+ }
307
+
308
+ // Auto-render MetadataRenderer trước khi render component
309
+ // MetadataRenderer sẽ push metadata vào headRegistry thông qua Head component
310
+ renderJsxToHtmlString(h(MetadataRenderer, {}));
311
+
312
+ // Auto-render schemas vào headRegistry
313
+ const schemas = getSchemas();
314
+ schemas.forEach((schema) => {
315
+ const schemaJson = JSON.stringify(schema);
316
+ // Push schema vào headRegistry thông qua Head component
317
+ // Head component chỉ push vào registry, không cần renderJsxToHtmlString
318
+ Head({
319
+ priority: HEAD_PRIORITY.SEO,
320
+ children: h('script', {
321
+ type: 'application/ld+json',
322
+ setHtml: schemaJson,
323
+ }),
324
+ });
325
+ });
326
+
327
+ // Render component (có thể có Head components khác)
328
+ const htmlBody = renderJsxToHtmlString(h(Component, props));
329
+ const clientEntries = getClientJsEntries();
330
+ const cssEntries = getCssEntries();
331
+ const islandEntries = getIslandEntries();
332
+ const cacheTags = getCacheTags();
333
+ const headContent = getHeadContent();
334
+
335
+ // Render head content to HTML string
336
+ const headHtml =
337
+ headContent.length > 0
338
+ ? headContent.map((content) => renderJsxToHtmlString(content)).join('\n ')
339
+ : undefined;
340
+
341
+ return {
342
+ html: htmlBody,
343
+ scripts: clientEntries.map((entry) => entry.path),
344
+ styles: cssEntries.map((entry) => entry.path),
345
+ islands: islandEntries.length > 0 ? islandEntries : undefined,
346
+ head: headHtml,
347
+ lang,
348
+ maxAge,
349
+ sMaxAge,
350
+ swr,
351
+ cacheTags,
352
+ rawHtml,
353
+ };
354
+ } catch (err: any) {
355
+ // Handle RedirectException
356
+ if (err instanceof RedirectException) {
357
+ return {
358
+ html: '',
359
+ redirect: {
360
+ url: err.url,
361
+ statusCode: err.statusCode,
362
+ },
363
+ };
364
+ }
365
+
366
+ // Handle HttpException
367
+ if (err instanceof HttpException) {
368
+ return await renderErrorView(err);
369
+ }
370
+
371
+ // For other errors, convert to ServiceUnavailableException
372
+ console.error(`Failed to render:`, err);
373
+ const serviceError = new ServiceUnavailableException(err.message || 'Internal Server Error', {
374
+ originalError: err.name,
375
+ stack: err.stack,
376
+ timestamp: new Date().toISOString(),
377
+ });
378
+ return await renderErrorView(serviceError);
379
+ }
380
+ }, requestInfo);
381
+ }