@punkestu/react-html 0.1.0 → 0.1.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.
Files changed (88) hide show
  1. package/dist/cli/docs-content.d.ts +8 -0
  2. package/dist/cli/docs-content.d.ts.map +1 -0
  3. package/dist/cli/docs-content.js +261 -0
  4. package/dist/cli/docs-content.js.map +1 -0
  5. package/dist/cli/index.d.ts +2 -0
  6. package/dist/cli/index.d.ts.map +1 -0
  7. package/dist/cli/index.js +113 -0
  8. package/dist/cli/index.js.map +1 -0
  9. package/dist/cli/tui-docs.d.ts +5 -0
  10. package/dist/cli/tui-docs.d.ts.map +1 -0
  11. package/dist/cli/tui-docs.js +191 -0
  12. package/dist/cli/tui-docs.js.map +1 -0
  13. package/dist/client/hydrate.d.ts +4 -0
  14. package/dist/client/hydrate.d.ts.map +1 -0
  15. package/dist/client/hydrate.js +80 -0
  16. package/dist/client/hydrate.js.map +1 -0
  17. package/dist/core/bundler.d.ts +7 -0
  18. package/dist/core/bundler.d.ts.map +1 -0
  19. package/dist/core/bundler.js +103 -0
  20. package/dist/core/bundler.js.map +1 -0
  21. package/dist/core/formatter.d.ts +6 -0
  22. package/dist/core/formatter.d.ts.map +1 -0
  23. package/dist/core/formatter.js +74 -0
  24. package/dist/core/formatter.js.map +1 -0
  25. package/dist/core/island-client.d.ts +9 -0
  26. package/dist/core/island-client.d.ts.map +1 -0
  27. package/dist/core/island-client.js +8 -0
  28. package/dist/core/island-client.js.map +1 -0
  29. package/dist/core/island-registry.d.ts +15 -0
  30. package/dist/core/island-registry.d.ts.map +1 -0
  31. package/dist/core/island-registry.js +17 -0
  32. package/dist/core/island-registry.js.map +1 -0
  33. package/dist/core/island.d.ts +32 -0
  34. package/dist/core/island.d.ts.map +1 -0
  35. package/dist/core/island.js +47 -0
  36. package/dist/core/island.js.map +1 -0
  37. package/dist/core/loader.d.ts +11 -0
  38. package/dist/core/loader.d.ts.map +1 -0
  39. package/dist/core/loader.js +73 -0
  40. package/dist/core/loader.js.map +1 -0
  41. package/dist/core/renderer.d.ts +20 -0
  42. package/dist/core/renderer.d.ts.map +1 -0
  43. package/dist/core/renderer.js +53 -0
  44. package/dist/core/renderer.js.map +1 -0
  45. package/dist/index.d.ts +23 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +104 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/server/dev-server.d.ts +8 -0
  50. package/dist/server/dev-server.d.ts.map +1 -0
  51. package/dist/server/dev-server.js +112 -0
  52. package/dist/server/dev-server.js.map +1 -0
  53. package/dist/server/engine.d.ts +46 -0
  54. package/dist/server/engine.d.ts.map +1 -0
  55. package/dist/server/engine.js +222 -0
  56. package/dist/server/engine.js.map +1 -0
  57. package/{src/server/index.ts → dist/server/index.d.ts} +1 -1
  58. package/dist/server/index.d.ts.map +1 -0
  59. package/dist/server/index.js +3 -0
  60. package/dist/server/index.js.map +1 -0
  61. package/package.json +4 -1
  62. package/.react-html/cache/page-fefe905ae5d2.mjs +0 -9
  63. package/DOCS.md +0 -317
  64. package/example/public/styles.css +0 -184
  65. package/example/src/components/Card.tsx +0 -17
  66. package/example/src/components/Counter.island.tsx +0 -35
  67. package/example/src/components/Footer.tsx +0 -13
  68. package/example/src/components/Header.tsx +0 -20
  69. package/example/src/components/Layout.tsx +0 -39
  70. package/example/src/components/Tabs.island.tsx +0 -59
  71. package/example/src/pages/index.tsx +0 -49
  72. package/example/src/pages/pure.tsx +0 -56
  73. package/src/cli/docs-content.ts +0 -268
  74. package/src/cli/index.ts +0 -113
  75. package/src/cli/tui-docs.ts +0 -214
  76. package/src/client/hydrate.ts +0 -84
  77. package/src/core/bundler.ts +0 -122
  78. package/src/core/formatter.ts +0 -92
  79. package/src/core/island-client.ts +0 -23
  80. package/src/core/island-registry.ts +0 -35
  81. package/src/core/island.tsx +0 -110
  82. package/src/core/loader.ts +0 -89
  83. package/src/core/renderer.ts +0 -80
  84. package/src/index.ts +0 -138
  85. package/src/server/dev-server.ts +0 -125
  86. package/src/server/engine.ts +0 -270
  87. package/tests/engine.test.mjs +0 -288
  88. package/tsconfig.json +0 -21
@@ -1,89 +0,0 @@
1
- import * as esbuild from 'esbuild';
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
- import { pathToFileURL } from 'node:url';
6
-
7
- export interface LoadedPage {
8
- Component: React.ComponentType<any>;
9
- metadata?: {
10
- title?: string;
11
- description?: string;
12
- [key: string]: unknown;
13
- };
14
- }
15
-
16
- const CACHE_DIR = path.resolve(process.cwd(), '.react-html/cache');
17
-
18
- // Plugin to replace import.meta.url with the real source file URL
19
- const importMetaUrlPlugin: esbuild.Plugin = {
20
- name: 'preserve-import-meta-url',
21
- setup(build) {
22
- build.onLoad({ filter: /\.(tsx|ts|jsx|js)$/ }, async (args) => {
23
- if (args.path.includes('node_modules')) return null;
24
- let text = await fs.readFile(args.path, 'utf8');
25
- if (text.includes('import.meta.url')) {
26
- const fileUrl = pathToFileURL(args.path).href;
27
- text = text.replaceAll('import.meta.url', JSON.stringify(fileUrl));
28
- const ext = path.extname(args.path).slice(1);
29
- return {
30
- contents: text,
31
- loader: (ext === 'js' || ext === 'jsx' || ext === 'ts' || ext === 'tsx') ? (ext as any) : 'tsx'
32
- };
33
- }
34
- return null;
35
- });
36
- }
37
- };
38
-
39
- export async function loadPageModule(pageFilePath: string): Promise<LoadedPage> {
40
- await fs.mkdir(CACHE_DIR, { recursive: true });
41
-
42
- const resolvedPath = path.resolve(pageFilePath);
43
- const hash = crypto.createHash('sha256').update(resolvedPath).digest('hex').slice(0, 12);
44
- const cacheFilePath = path.join(CACHE_DIR, `page-${hash}.mjs`);
45
-
46
- // Build the page bundle for Node.js execution
47
- await esbuild.build({
48
- entryPoints: [resolvedPath],
49
- outfile: cacheFilePath,
50
- bundle: true,
51
- format: 'esm',
52
- platform: 'node',
53
- target: 'node22',
54
- packages: 'external',
55
- sourcemap: 'inline',
56
- plugins: [importMetaUrlPlugin],
57
- loader: {
58
- '.tsx': 'tsx',
59
- '.ts': 'ts',
60
- '.jsx': 'jsx',
61
- '.js': 'js',
62
- '.css': 'empty'
63
- }
64
- });
65
-
66
- // Dynamic import with timestamp cache buster
67
- const fileUrl = `${pathToFileURL(cacheFilePath).href}?t=${Date.now()}`;
68
- const importedModule = await import(fileUrl);
69
-
70
- const Component = importedModule.default;
71
- if (!Component) {
72
- throw new Error(`Page "${pageFilePath}" does not have a default export. Please provide "export default function Page()".`);
73
- }
74
-
75
- const { default: _, ...metadata } = importedModule;
76
-
77
- return {
78
- Component,
79
- metadata
80
- };
81
- }
82
-
83
- export async function clearCache(): Promise<void> {
84
- try {
85
- await fs.rm(CACHE_DIR, { recursive: true, force: true });
86
- } catch {
87
- // Ignore cache cleanup errors
88
- }
89
- }
@@ -1,80 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { runWithRenderContext, IslandInstance } from './island-registry.js';
4
- import { formatHtml } from './formatter.js';
5
-
6
- export interface RenderResult {
7
- html: string;
8
- islands: IslandInstance[];
9
- }
10
-
11
- export interface RenderOptions {
12
- pagePath: string;
13
- Component: React.ComponentType<any>;
14
- props?: Record<string, unknown>;
15
- metadata?: {
16
- title?: string;
17
- description?: string;
18
- lang?: string;
19
- [key: string]: unknown;
20
- };
21
- basePath?: string;
22
- }
23
-
24
- export function renderPageToHtml(options: RenderOptions): RenderResult {
25
- const { pagePath, Component, props = {}, metadata = {}, basePath = '' } = options;
26
- const islands: IslandInstance[] = [];
27
-
28
- const rawMarkup = runWithRenderContext(
29
- { pagePath, islands },
30
- () => renderToStaticMarkup(React.createElement(Component, props))
31
- );
32
-
33
- let finalHtml = rawMarkup;
34
- const hasHtmlTag = /<html[^>]*>/i.test(finalHtml);
35
- const hasDoctype = /<!DOCTYPE html>/i.test(finalHtml);
36
-
37
- // If component returned only body contents / fragments, wrap in standard HTML shell
38
- if (!hasHtmlTag) {
39
- const lang = metadata.lang || 'en';
40
- const titleTag = metadata.title ? `<title>${metadata.title}</title>` : '';
41
- const descTag = metadata.description ? `<meta name="description" content="${metadata.description}">` : '';
42
-
43
- finalHtml = `<!DOCTYPE html>
44
- <html lang="${lang}">
45
- <head>
46
- <meta charset="UTF-8">
47
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
48
- ${titleTag}
49
- ${descTag}
50
- </head>
51
- <body>
52
- ${finalHtml}
53
- </body>
54
- </html>`;
55
- } else if (!hasDoctype) {
56
- finalHtml = `<!DOCTYPE html>\n${finalHtml}`;
57
- }
58
-
59
- // If there are islands, inject script tags before </body> or at the end of the file
60
- if (islands.length > 0) {
61
- // Collect unique island names
62
- const uniqueIslands = Array.from(new Set(islands.map(i => i.name)));
63
- const assetPrefix = basePath ? (basePath.endsWith('/') ? basePath : `${basePath}/`) : '/';
64
- const scriptTags = uniqueIslands
65
- .map(name => `<script type="module" src="${assetPrefix}assets/islands/${name}.js"></script>`)
66
- .join('\n');
67
-
68
- if (/<\/body>/i.test(finalHtml)) {
69
- finalHtml = finalHtml.replace(/<\/body>/i, `${scriptTags}\n</body>`);
70
- } else {
71
- finalHtml = `${finalHtml}\n${scriptTags}`;
72
- }
73
- }
74
-
75
- return {
76
- html: formatHtml(finalHtml),
77
- islands
78
- };
79
- }
80
-
package/src/index.ts DELETED
@@ -1,138 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { loadPageModule, clearCache } from './core/loader.js';
4
- import { renderPageToHtml } from './core/renderer.js';
5
- import { bundleIslands } from './core/bundler.js';
6
- import { IslandInstance } from './core/island-registry.js';
7
-
8
- export { Island, island } from './core/island.js';
9
- export type { IslandProps } from './core/island.js';
10
- export { hydrateIsland } from './client/hydrate.js';
11
- export { renderPageToHtml } from './core/renderer.js';
12
- export { createEngine, ReactHtmlEngine } from './server/engine.js';
13
- export type { EngineOptions } from './server/engine.js';
14
-
15
- export interface BuildConfig {
16
- root?: string;
17
- pagesDir?: string;
18
- outDir?: string;
19
- publicDir?: string;
20
- minify?: boolean;
21
- }
22
-
23
- export interface BuildResult {
24
- pages: { input: string; output: string }[];
25
- islands: string[];
26
- outDir: string;
27
- }
28
-
29
- async function findFilesRecursively(dir: string, extensions: string[]): Promise<string[]> {
30
- const entries = await fs.readdir(dir, { withFileTypes: true });
31
- const files: string[] = [];
32
-
33
- for (const entry of entries) {
34
- const fullPath = path.join(dir, entry.name);
35
- if (entry.isDirectory()) {
36
- files.push(...(await findFilesRecursively(fullPath, extensions)));
37
- } else if (entry.isFile()) {
38
- const ext = path.extname(entry.name);
39
- if (extensions.includes(ext) && !entry.name.startsWith('_') && !entry.name.includes('.test.')) {
40
- files.push(fullPath);
41
- }
42
- }
43
- }
44
-
45
- return files;
46
- }
47
-
48
- export async function build(config: BuildConfig = {}): Promise<BuildResult> {
49
- const root = path.resolve(config.root || process.cwd());
50
- const outDir = path.resolve(root, config.outDir || 'dist');
51
- const minify = config.minify ?? true;
52
-
53
- // Locate pages directory
54
- let pagesDir = config.pagesDir ? path.resolve(root, config.pagesDir) : '';
55
- if (!pagesDir) {
56
- const candidateDirs = ['src/pages', 'pages'];
57
- for (const cand of candidateDirs) {
58
- const p = path.join(root, cand);
59
- try {
60
- const stat = await fs.stat(p);
61
- if (stat.isDirectory()) {
62
- pagesDir = p;
63
- break;
64
- }
65
- } catch {
66
- // Continue checking
67
- }
68
- }
69
- }
70
-
71
- if (!pagesDir) {
72
- throw new Error(`Pages directory not found. Please create 'src/pages' or specify 'pagesDir'.`);
73
- }
74
-
75
- // Ensure output directory exists and is clean
76
- await fs.mkdir(outDir, { recursive: true });
77
-
78
- // Copy public static directory if present
79
- const publicDir = path.resolve(root, config.publicDir || 'public');
80
- try {
81
- const publicStat = await fs.stat(publicDir);
82
- if (publicStat.isDirectory()) {
83
- await fs.cp(publicDir, outDir, { recursive: true });
84
- }
85
- } catch {
86
- // Public directory optional
87
- }
88
-
89
- const pageFiles = await findFilesRecursively(pagesDir, ['.tsx', '.jsx', '.ts', '.js']);
90
- if (pageFiles.length === 0) {
91
- console.warn(`[react-html] No pages found in ${pagesDir}`);
92
- }
93
-
94
- const allDiscoveredIslands: IslandInstance[] = [];
95
- const processedPages: { input: string; output: string }[] = [];
96
-
97
- for (const pageFile of pageFiles) {
98
- const relativePage = path.relative(pagesDir, pageFile);
99
- const parsed = path.parse(relativePage);
100
-
101
- // Compute destination path: index.tsx -> index.html, about.tsx -> about.html
102
- const outHtmlFile = path.join(outDir, parsed.dir, `${parsed.name}.html`);
103
- await fs.mkdir(path.dirname(outHtmlFile), { recursive: true });
104
-
105
- // Load page module
106
- const { Component, metadata } = await loadPageModule(pageFile);
107
-
108
- // Render to pure HTML and extract islands
109
- const { html, islands } = renderPageToHtml({
110
- pagePath: pageFile,
111
- Component,
112
- metadata
113
- });
114
-
115
- await fs.writeFile(outHtmlFile, html, 'utf8');
116
- processedPages.push({ input: pageFile, output: outHtmlFile });
117
-
118
- if (islands.length > 0) {
119
- allDiscoveredIslands.push(...islands);
120
- }
121
- }
122
-
123
- // Bundle reactive islands
124
- let bundledIslands: string[] = [];
125
- if (allDiscoveredIslands.length > 0) {
126
- bundledIslands = await bundleIslands(allDiscoveredIslands, { outDir, minify });
127
- }
128
-
129
- // Cleanup compile cache
130
- await clearCache();
131
-
132
- return {
133
- pages: processedPages,
134
- islands: bundledIslands,
135
- outDir
136
- };
137
- }
138
-
@@ -1,125 +0,0 @@
1
- import http from 'node:http';
2
- import fs from 'node:fs/promises';
3
- import { existsSync, watch } from 'node:fs';
4
- import path from 'node:path';
5
- import { build, BuildConfig } from '../index.js';
6
-
7
- const MIME_TYPES: Record<string, string> = {
8
- '.html': 'text/html; charset=utf-8',
9
- '.js': 'application/javascript; charset=utf-8',
10
- '.mjs': 'application/javascript; charset=utf-8',
11
- '.css': 'text/css; charset=utf-8',
12
- '.json': 'application/json; charset=utf-8',
13
- '.png': 'image/png',
14
- '.jpg': 'image/jpeg',
15
- '.jpeg': 'image/jpeg',
16
- '.gif': 'image/gif',
17
- '.svg': 'image/svg+xml',
18
- '.ico': 'image/x-icon',
19
- '.woff2': 'font/woff2'
20
- };
21
-
22
- export interface DevServerOptions extends BuildConfig {
23
- port?: number;
24
- host?: string;
25
- }
26
-
27
- export async function createDevServer(options: DevServerOptions = {}): Promise<http.Server> {
28
- const port = options.port || 3000;
29
- const host = options.host || 'localhost';
30
- const root = path.resolve(options.root || process.cwd());
31
- const outDir = path.resolve(root, options.outDir || 'dist');
32
-
33
- console.log(`[react-html] Performing initial build...`);
34
- await build({ ...options, minify: false });
35
- console.log(`[react-html] Build completed.`);
36
-
37
- // Setup file watcher with debounce
38
- let isRebuilding = false;
39
- let rebuildTimeout: NodeJS.Timeout | null = null;
40
-
41
- const triggerRebuild = () => {
42
- if (rebuildTimeout) clearTimeout(rebuildTimeout);
43
- rebuildTimeout = setTimeout(async () => {
44
- if (isRebuilding) return;
45
- isRebuilding = true;
46
- try {
47
- console.log(`[react-html] Change detected, rebuilding...`);
48
- const startTime = Date.now();
49
- await build({ ...options, minify: false });
50
- console.log(`[react-html] Rebuild finished in ${Date.now() - startTime}ms`);
51
- } catch (err) {
52
- console.error(`[react-html] Rebuild error:`, err);
53
- } finally {
54
- isRebuilding = false;
55
- }
56
- }, 100);
57
- };
58
-
59
- const watchDirs = ['src', 'pages', 'public'].map(d => path.join(root, d)).filter(existsSync);
60
- for (const dir of watchDirs) {
61
- try {
62
- watch(dir, { recursive: true }, (event, filename) => {
63
- if (filename && !filename.includes('.react-html') && !filename.includes('dist')) {
64
- triggerRebuild();
65
- }
66
- });
67
- } catch {
68
- // Fallback
69
- }
70
- }
71
-
72
- const server = http.createServer(async (req, res) => {
73
- try {
74
- const url = new URL(req.url || '/', `http://${host}:${port}`);
75
- let pathname = decodeURIComponent(url.pathname);
76
-
77
- if (pathname.endsWith('/')) {
78
- pathname += 'index.html';
79
- }
80
-
81
- let filePath = path.join(outDir, pathname);
82
-
83
- // If no extension, check if an .html file exists
84
- if (!path.extname(filePath)) {
85
- if (existsSync(`${filePath}.html`)) {
86
- filePath = `${filePath}.html`;
87
- } else if (existsSync(path.join(filePath, 'index.html'))) {
88
- filePath = path.join(filePath, 'index.html');
89
- }
90
- }
91
-
92
- if (!existsSync(filePath)) {
93
- res.writeHead(404, { 'Content-Type': 'text/html' });
94
- res.end(`<h1>404 Not Found</h1><p>File not found: ${pathname}</p>`);
95
- return;
96
- }
97
-
98
- const stat = await fs.stat(filePath);
99
- if (stat.isDirectory()) {
100
- filePath = path.join(filePath, 'index.html');
101
- }
102
-
103
- const ext = path.extname(filePath).toLowerCase();
104
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
105
-
106
- const content = await fs.readFile(filePath);
107
- res.writeHead(200, {
108
- 'Content-Type': contentType,
109
- 'Content-Length': content.length
110
- });
111
- res.end(content);
112
- } catch (err: any) {
113
- res.writeHead(500, { 'Content-Type': 'text/plain' });
114
- res.end(`500 Internal Server Error: ${err?.message || err}`);
115
- }
116
- });
117
-
118
- return new Promise((resolve) => {
119
- server.listen(port, host, () => {
120
- console.log(`[react-html] Dev server running at http://${host}:${port}/`);
121
- resolve(server);
122
- });
123
- });
124
- }
125
-
@@ -1,270 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import path from 'node:path';
4
- import type { IncomingMessage, ServerResponse } from 'node:http';
5
- import { loadPageModule } from '../core/loader.js';
6
- import { renderPageToHtml } from '../core/renderer.js';
7
- import { bundleIslands } from '../core/bundler.js';
8
- import { IslandInstance } from '../core/island-registry.js';
9
-
10
- export interface EngineOptions {
11
- /** Project root directory (default: process.cwd()) */
12
- root?: string;
13
- /** Source pages directory (default: 'src/pages' or 'pages') */
14
- pagesDir?: string;
15
- /** Production build output directory (default: 'dist') */
16
- distDir?: string;
17
- /** Static public directory (default: 'public') */
18
- publicDir?: string;
19
- /** Force production mode. If omitted, checks process.env.NODE_ENV === 'production' */
20
- isProduction?: boolean;
21
- /** Base asset URL prefix (default: '/') */
22
- basePath?: string;
23
- }
24
-
25
- const MIME_TYPES: Record<string, string> = {
26
- '.html': 'text/html; charset=utf-8',
27
- '.js': 'application/javascript; charset=utf-8',
28
- '.mjs': 'application/javascript; charset=utf-8',
29
- '.css': 'text/css; charset=utf-8',
30
- '.json': 'application/json; charset=utf-8',
31
- '.png': 'image/png',
32
- '.jpg': 'image/jpeg',
33
- '.jpeg': 'image/jpeg',
34
- '.gif': 'image/gif',
35
- '.svg': 'image/svg+xml',
36
- '.ico': 'image/x-icon',
37
- '.woff2': 'font/woff2'
38
- };
39
-
40
- export class ReactHtmlEngine {
41
- public readonly root: string;
42
- public readonly pagesDir: string;
43
- public readonly distDir: string;
44
- public readonly publicDir: string;
45
- public readonly isProduction: boolean;
46
- public readonly basePath: string;
47
-
48
- constructor(options: EngineOptions = {}) {
49
- this.root = path.resolve(options.root || process.cwd());
50
- this.isProduction =
51
- options.isProduction !== undefined
52
- ? options.isProduction
53
- : process.env.NODE_ENV === 'production';
54
- this.basePath = options.basePath || '/';
55
-
56
- // Resolve distDir
57
- this.distDir = path.resolve(this.root, options.distDir || 'dist');
58
- this.publicDir = path.resolve(this.root, options.publicDir || 'public');
59
-
60
- // Resolve pagesDir
61
- if (options.pagesDir) {
62
- this.pagesDir = path.resolve(this.root, options.pagesDir);
63
- } else {
64
- const candidates = ['src/pages', 'pages'];
65
- let found = path.join(this.root, 'src/pages');
66
- for (const cand of candidates) {
67
- const p = path.join(this.root, cand);
68
- if (existsSync(p)) {
69
- found = p;
70
- break;
71
- }
72
- }
73
- this.pagesDir = found;
74
- }
75
- }
76
-
77
- /**
78
- * Render a page by name or route.
79
- * - In production: checks for pre-built static HTML in dist/ first.
80
- * - In development: recompiles the TSX page on the fly and bundles any needed reactive islands.
81
- */
82
- public async render(
83
- pageRoute: string,
84
- props: Record<string, unknown> = {},
85
- metadata: Record<string, unknown> = {}
86
- ): Promise<string> {
87
- const cleanRoute = pageRoute.replace(/^\/+/, '').replace(/\.html$/, '');
88
- const normalizedName = cleanRoute === '' ? 'index' : cleanRoute;
89
-
90
- // In production, check for pre-built static HTML
91
- if (this.isProduction) {
92
- const candidatePaths = [
93
- path.join(this.distDir, `${normalizedName}.html`),
94
- path.join(this.distDir, normalizedName, 'index.html')
95
- ];
96
-
97
- for (const candidate of candidatePaths) {
98
- if (existsSync(candidate)) {
99
- return await fs.readFile(candidate, 'utf8');
100
- }
101
- }
102
- }
103
-
104
- // In development (or if production pre-build not found), compile on-the-fly
105
- const sourceFilePath = await this.resolveSourcePage(normalizedName);
106
- if (!sourceFilePath) {
107
- throw new Error(
108
- `[react-html] Page "${pageRoute}" not found in "${this.pagesDir}".`
109
- );
110
- }
111
-
112
- const { Component, metadata: pageMeta } = await loadPageModule(sourceFilePath);
113
- const mergedMetadata = { ...pageMeta, ...metadata };
114
-
115
- const { html, islands } = renderPageToHtml({
116
- pagePath: sourceFilePath,
117
- Component,
118
- props,
119
- metadata: mergedMetadata,
120
- basePath: this.basePath
121
- });
122
-
123
- // In development, bundle reactive islands on-the-fly into distDir/assets/islands
124
- if (islands.length > 0) {
125
- await bundleIslands(islands, {
126
- outDir: this.distDir,
127
- minify: this.isProduction
128
- });
129
- }
130
-
131
- return html;
132
- }
133
-
134
- /**
135
- * Universal Connect / Express / Node.js HTTP middleware.
136
- * Handles page routes and static assets according to environment mode.
137
- */
138
- public middleware = async (
139
- req: IncomingMessage,
140
- res: ServerResponse,
141
- next?: () => void
142
- ): Promise<void> => {
143
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
144
- let pathname = decodeURIComponent(url.pathname);
145
-
146
- // 1. Check if request matches an asset in dist/ or public/
147
- const assetResponse = await this.tryServeStaticAsset(pathname);
148
- if (assetResponse) {
149
- res.writeHead(200, {
150
- 'Content-Type': assetResponse.contentType,
151
- 'Content-Length': assetResponse.content.length
152
- });
153
- res.end(assetResponse.content);
154
- return;
155
- }
156
-
157
- // 2. Normalize page route
158
- let route = pathname.replace(/^\/+/, '').replace(/\/+$/, '');
159
- if (route === '' || route.endsWith('.html')) {
160
- route = route.replace(/\.html$/, '') || 'index';
161
- }
162
-
163
- // 3. Try rendering the page
164
- try {
165
- // In production: check if dist HTML exists
166
- if (this.isProduction) {
167
- const candidatePaths = [
168
- path.join(this.distDir, `${route}.html`),
169
- path.join(this.distDir, route, 'index.html')
170
- ];
171
- for (const candidate of candidatePaths) {
172
- if (existsSync(candidate)) {
173
- const content = await fs.readFile(candidate);
174
- res.writeHead(200, {
175
- 'Content-Type': 'text/html; charset=utf-8',
176
- 'Content-Length': content.length
177
- });
178
- res.end(content);
179
- return;
180
- }
181
- }
182
- } else {
183
- // In development: check if source page exists
184
- const sourcePath = await this.resolveSourcePage(route);
185
- if (sourcePath) {
186
- const html = await this.render(route);
187
- const buffer = Buffer.from(html, 'utf8');
188
- res.writeHead(200, {
189
- 'Content-Type': 'text/html; charset=utf-8',
190
- 'Content-Length': buffer.length
191
- });
192
- res.end(buffer);
193
- return;
194
- }
195
- }
196
- } catch (err: any) {
197
- console.error(`[react-html] Engine rendering error for route "${pathname}":`, err);
198
- res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
199
- res.end(`500 Internal Server Error: ${err?.message || err}`);
200
- return;
201
- }
202
-
203
- // If not handled, pass to next middleware if available
204
- if (typeof next === 'function') {
205
- next();
206
- } else {
207
- res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
208
- res.end(`<h1>404 Not Found</h1><p>Route "${pathname}" not found.</p>`);
209
- }
210
- };
211
-
212
- /**
213
- * Standalone HTTP request handler suitable for http.createServer(engine.handle)
214
- */
215
- public handle = (req: IncomingMessage, res: ServerResponse): Promise<void> => {
216
- return this.middleware(req, res);
217
- };
218
-
219
- private async resolveSourcePage(normalizedName: string): Promise<string | null> {
220
- const extensions = ['.tsx', '.jsx', '.ts', '.js'];
221
- const candidates = [
222
- ...extensions.map(ext => path.join(this.pagesDir, `${normalizedName}${ext}`)),
223
- ...extensions.map(ext => path.join(this.pagesDir, normalizedName, `index${ext}`))
224
- ];
225
-
226
- for (const cand of candidates) {
227
- if (existsSync(cand)) {
228
- return cand;
229
- }
230
- }
231
- return null;
232
- }
233
-
234
- private async tryServeStaticAsset(
235
- pathname: string
236
- ): Promise<{ content: Buffer; contentType: string } | null> {
237
- const ext = path.extname(pathname).toLowerCase();
238
- if (!ext || ext === '.html') return null;
239
-
240
- const cleanPath = pathname.replace(/^\/+/, '');
241
- const candidateFiles = [
242
- path.join(this.distDir, cleanPath),
243
- path.join(this.publicDir, cleanPath)
244
- ];
245
-
246
- for (const filePath of candidateFiles) {
247
- if (existsSync(filePath)) {
248
- try {
249
- const stat = await fs.stat(filePath);
250
- if (stat.isFile()) {
251
- const content = await fs.readFile(filePath);
252
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
253
- return { content, contentType };
254
- }
255
- } catch {
256
- // Continue searching
257
- }
258
- }
259
- }
260
- return null;
261
- }
262
- }
263
-
264
- /**
265
- * Factory function to create a new ReactHtmlEngine instance
266
- */
267
- export function createEngine(options: EngineOptions = {}): ReactHtmlEngine {
268
- return new ReactHtmlEngine(options);
269
- }
270
-