@punkestu/react-html 0.0.0-stage → 0.1.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.
- package/.react-html/cache/page-fefe905ae5d2.mjs +9 -0
- package/DOCS.md +317 -0
- package/README.md +215 -2
- package/bin/react-html.js +8 -0
- package/example/public/styles.css +184 -0
- package/example/src/components/Card.tsx +17 -0
- package/example/src/components/Counter.island.tsx +35 -0
- package/example/src/components/Footer.tsx +13 -0
- package/example/src/components/Header.tsx +20 -0
- package/example/src/components/Layout.tsx +39 -0
- package/example/src/components/Tabs.island.tsx +59 -0
- package/example/src/pages/index.tsx +49 -0
- package/example/src/pages/pure.tsx +56 -0
- package/package.json +44 -3
- package/src/cli/docs-content.ts +268 -0
- package/src/cli/index.ts +113 -0
- package/src/cli/tui-docs.ts +214 -0
- package/src/client/hydrate.ts +84 -0
- package/src/core/bundler.ts +122 -0
- package/src/core/formatter.ts +92 -0
- package/src/core/island-client.ts +23 -0
- package/src/core/island-registry.ts +35 -0
- package/src/core/island.tsx +110 -0
- package/src/core/loader.ts +89 -0
- package/src/core/renderer.ts +80 -0
- package/src/index.ts +138 -0
- package/src/server/dev-server.ts +125 -0
- package/src/server/engine.ts +270 -0
- package/src/server/index.ts +5 -0
- package/tests/engine.test.mjs +288 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { test, describe, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { renderPageToHtml, build, Island, island } from '../dist/index.js';
|
|
7
|
+
|
|
8
|
+
describe('react-html Engine Test Suite', () => {
|
|
9
|
+
describe('Static HTML Rendering', () => {
|
|
10
|
+
test('renders pure static component to HTML with zero script tags', () => {
|
|
11
|
+
function StaticPage() {
|
|
12
|
+
return React.createElement(
|
|
13
|
+
'div',
|
|
14
|
+
{ className: 'hero' },
|
|
15
|
+
React.createElement('h1', null, 'Hello Pure World'),
|
|
16
|
+
React.createElement('p', null, 'This is pure static HTML without JS.')
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = renderPageToHtml({
|
|
21
|
+
pagePath: '/test/page.tsx',
|
|
22
|
+
Component: StaticPage,
|
|
23
|
+
metadata: { title: 'Static Test' }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.ok(result.html.includes('<!DOCTYPE html>'));
|
|
27
|
+
assert.ok(result.html.includes('<title>Static Test</title>'));
|
|
28
|
+
assert.ok(result.html.includes('<h1>Hello Pure World</h1>') || result.html.includes('Hello Pure World'));
|
|
29
|
+
assert.ok(!result.html.includes('<script'));
|
|
30
|
+
assert.equal(result.islands.length, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('preserves full html document when component returns html tag', () => {
|
|
34
|
+
function FullDoc() {
|
|
35
|
+
return React.createElement(
|
|
36
|
+
'html',
|
|
37
|
+
{ lang: 'fr' },
|
|
38
|
+
React.createElement('head', null, React.createElement('title', null, 'French Page')),
|
|
39
|
+
React.createElement('body', null, React.createElement('h1', null, 'Bonjour'))
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = renderPageToHtml({
|
|
44
|
+
pagePath: '/test/french.tsx',
|
|
45
|
+
Component: FullDoc
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.ok(result.html.startsWith('<!DOCTYPE html>'));
|
|
49
|
+
assert.ok(result.html.includes('<html lang="fr">'));
|
|
50
|
+
assert.ok(result.html.includes('Bonjour'));
|
|
51
|
+
assert.ok(!result.html.includes('<script'));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('Reactive Island Architecture', () => {
|
|
56
|
+
test('registers island and injects script tag for interactive component', () => {
|
|
57
|
+
function MyCounter({ initial = 5 }) {
|
|
58
|
+
return React.createElement('button', null, `Count: ${initial}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const InteractiveCounter = island(MyCounter, '/fake/path/Counter.tsx', 'MyCounter');
|
|
62
|
+
|
|
63
|
+
function PageWithIsland() {
|
|
64
|
+
return React.createElement(
|
|
65
|
+
'main',
|
|
66
|
+
null,
|
|
67
|
+
React.createElement('h1', null, 'Island Page'),
|
|
68
|
+
React.createElement(InteractiveCounter, { initial: 10 })
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = renderPageToHtml({
|
|
73
|
+
pagePath: '/test/island-page.tsx',
|
|
74
|
+
Component: PageWithIsland
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.equal(result.islands.length, 1);
|
|
78
|
+
assert.equal(result.islands[0].name, 'MyCounter');
|
|
79
|
+
assert.deepEqual(result.islands[0].props, { initial: 10 });
|
|
80
|
+
assert.ok(result.html.includes('data-island="MyCounter"'));
|
|
81
|
+
assert.ok(result.html.includes('data-props="{"initial":10}"'));
|
|
82
|
+
assert.ok(result.html.includes('<script type="module" src="/assets/islands/MyCounter.js">'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('End-to-End Build Pipeline', () => {
|
|
87
|
+
const testFixtureDir = path.resolve(process.cwd(), '.test-fixture');
|
|
88
|
+
const outDir = path.join(testFixtureDir, 'dist');
|
|
89
|
+
|
|
90
|
+
before(async () => {
|
|
91
|
+
await fs.mkdir(path.join(testFixtureDir, 'src/pages'), { recursive: true });
|
|
92
|
+
await fs.mkdir(path.join(testFixtureDir, 'src/components'), { recursive: true });
|
|
93
|
+
|
|
94
|
+
// Create a pure static page
|
|
95
|
+
await fs.writeFile(
|
|
96
|
+
path.join(testFixtureDir, 'src/pages/pure.tsx'),
|
|
97
|
+
`
|
|
98
|
+
import React from 'react';
|
|
99
|
+
export const title = "Pure Static";
|
|
100
|
+
export default function Pure() {
|
|
101
|
+
return <div><h1>Zero JS</h1></div>;
|
|
102
|
+
}
|
|
103
|
+
`
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Create an island component
|
|
107
|
+
await fs.writeFile(
|
|
108
|
+
path.join(testFixtureDir, 'src/components/Toggle.island.tsx'),
|
|
109
|
+
`
|
|
110
|
+
import React, { useState } from 'react';
|
|
111
|
+
import { island } from '${path.resolve(process.cwd(), 'dist/index.js')}';
|
|
112
|
+
function ToggleComp({ label = 'Click' }) {
|
|
113
|
+
const [on, setOn] = useState(false);
|
|
114
|
+
return <button onClick={() => setOn(!on)}>{label}: {on ? 'ON' : 'OFF'}</button>;
|
|
115
|
+
}
|
|
116
|
+
export const Toggle = island(ToggleComp, import.meta.url, 'Toggle');
|
|
117
|
+
export default Toggle;
|
|
118
|
+
`
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Create a page using the island
|
|
122
|
+
await fs.writeFile(
|
|
123
|
+
path.join(testFixtureDir, 'src/pages/interactive.tsx'),
|
|
124
|
+
`
|
|
125
|
+
import React from 'react';
|
|
126
|
+
import { Toggle } from '../components/Toggle.island.js';
|
|
127
|
+
export default function Interactive() {
|
|
128
|
+
return <div><h1>Interactive</h1><Toggle label="Power" /></div>;
|
|
129
|
+
}
|
|
130
|
+
`
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
after(async () => {
|
|
135
|
+
await fs.rm(testFixtureDir, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('builds static pages and bundles reactive islands correctly', async () => {
|
|
139
|
+
const result = await build({
|
|
140
|
+
root: testFixtureDir,
|
|
141
|
+
outDir: 'dist',
|
|
142
|
+
minify: true
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
assert.equal(result.pages.length, 2);
|
|
146
|
+
|
|
147
|
+
// Check pure static HTML output: absolutely NO script tags
|
|
148
|
+
const pureHtml = await fs.readFile(path.join(outDir, 'pure.html'), 'utf8');
|
|
149
|
+
assert.ok(pureHtml.includes('Zero JS'));
|
|
150
|
+
assert.ok(!pureHtml.includes('<script'));
|
|
151
|
+
|
|
152
|
+
// Check interactive page HTML output: has island markup & script tag
|
|
153
|
+
const interactiveHtml = await fs.readFile(path.join(outDir, 'interactive.html'), 'utf8');
|
|
154
|
+
assert.ok(interactiveHtml.includes('Interactive'));
|
|
155
|
+
assert.ok(interactiveHtml.includes('data-island="Toggle"'));
|
|
156
|
+
assert.ok(interactiveHtml.includes('<script type="module" src="/assets/islands/Toggle.js">'));
|
|
157
|
+
|
|
158
|
+
// Check island JS bundle was generated
|
|
159
|
+
const islandJsExists = await fs.stat(path.join(outDir, 'assets/islands/Toggle.js')).then(() => true).catch(() => false);
|
|
160
|
+
assert.ok(islandJsExists, 'Toggle.js island bundle should exist in assets/islands');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('Terminal Documentation (TUI)', () => {
|
|
165
|
+
test('contains comprehensive documentation sections', async () => {
|
|
166
|
+
const { DOC_SECTIONS } = await import('../dist/cli/docs-content.js');
|
|
167
|
+
assert.ok(DOC_SECTIONS.length >= 8, 'Expected at least 8 doc sections');
|
|
168
|
+
|
|
169
|
+
const ids = DOC_SECTIONS.map(s => s.id);
|
|
170
|
+
assert.ok(ids.includes('intro'));
|
|
171
|
+
assert.ok(ids.includes('islands'));
|
|
172
|
+
assert.ok(ids.includes('directives'));
|
|
173
|
+
assert.ok(ids.includes('cli'));
|
|
174
|
+
assert.ok(ids.includes('troubleshooting'));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('Server Application Library (createEngine)', () => {
|
|
179
|
+
const fixtureDir = path.resolve(process.cwd(), '.server-test-fixture');
|
|
180
|
+
const pagesDir = path.join(fixtureDir, 'src/pages');
|
|
181
|
+
const distDir = path.join(fixtureDir, 'dist');
|
|
182
|
+
|
|
183
|
+
before(async () => {
|
|
184
|
+
await fs.mkdir(pagesDir, { recursive: true });
|
|
185
|
+
await fs.mkdir(distDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
// Create pre-built production HTML in dist
|
|
188
|
+
await fs.writeFile(
|
|
189
|
+
path.join(distDir, 'index.html'),
|
|
190
|
+
'<!DOCTYPE html><html><body><h1>PRE-BUILT PRODUCTION HTML</h1></body></html>'
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Create development source page in src/pages
|
|
194
|
+
await fs.writeFile(
|
|
195
|
+
path.join(pagesDir, 'index.tsx'),
|
|
196
|
+
`
|
|
197
|
+
import React from 'react';
|
|
198
|
+
export default function Home() {
|
|
199
|
+
return <h1>LIVE REBUILT DEV HTML</h1>;
|
|
200
|
+
}
|
|
201
|
+
`
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
after(async () => {
|
|
206
|
+
await fs.rm(fixtureDir, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('production mode: loads and returns pre-built static HTML from dist', async () => {
|
|
210
|
+
const { createEngine } = await import('../dist/index.js');
|
|
211
|
+
const engine = createEngine({
|
|
212
|
+
root: fixtureDir,
|
|
213
|
+
pagesDir,
|
|
214
|
+
distDir,
|
|
215
|
+
isProduction: true
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const html = await engine.render('index');
|
|
219
|
+
assert.ok(html.includes('PRE-BUILT PRODUCTION HTML'), 'Production should return pre-built dist HTML');
|
|
220
|
+
assert.ok(!html.includes('LIVE REBUILT DEV HTML'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('development mode: dynamically rebuilds and returns fresh HTML string', async () => {
|
|
224
|
+
const { createEngine } = await import('../dist/index.js');
|
|
225
|
+
const engine = createEngine({
|
|
226
|
+
root: fixtureDir,
|
|
227
|
+
pagesDir,
|
|
228
|
+
distDir,
|
|
229
|
+
isProduction: false
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const html = await engine.render('index');
|
|
233
|
+
assert.ok(html.includes('LIVE REBUILT DEV HTML'), 'Development should rebuild from source TSX');
|
|
234
|
+
assert.ok(!html.includes('PRE-BUILT PRODUCTION HTML'));
|
|
235
|
+
|
|
236
|
+
// Modify the source file on the fly
|
|
237
|
+
await fs.writeFile(
|
|
238
|
+
path.join(pagesDir, 'index.tsx'),
|
|
239
|
+
`
|
|
240
|
+
import React from 'react';
|
|
241
|
+
export default function Home() {
|
|
242
|
+
return <h1>UPDATED LIVE IN DEV</h1>;
|
|
243
|
+
}
|
|
244
|
+
`
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Re-render: should reflect the immediate update without server restart!
|
|
248
|
+
const updatedHtml = await engine.render('index');
|
|
249
|
+
assert.ok(updatedHtml.includes('UPDATED LIVE IN DEV'), 'Dev mode should reflect immediate code edits');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('middleware: serves request via connect/express-compatible middleware', async () => {
|
|
253
|
+
const { createEngine } = await import('../dist/index.js');
|
|
254
|
+
const engine = createEngine({
|
|
255
|
+
root: fixtureDir,
|
|
256
|
+
pagesDir,
|
|
257
|
+
distDir,
|
|
258
|
+
isProduction: true
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
let statusCode = 0;
|
|
262
|
+
let headers = {};
|
|
263
|
+
let body = '';
|
|
264
|
+
|
|
265
|
+
const fakeReq = {
|
|
266
|
+
url: '/',
|
|
267
|
+
headers: { host: 'localhost:3000' }
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const fakeRes = {
|
|
271
|
+
writeHead(status, h) {
|
|
272
|
+
statusCode = status;
|
|
273
|
+
headers = h;
|
|
274
|
+
},
|
|
275
|
+
end(data) {
|
|
276
|
+
body = data.toString();
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
await engine.middleware(fakeReq, fakeRes);
|
|
281
|
+
|
|
282
|
+
assert.equal(statusCode, 200);
|
|
283
|
+
assert.equal(headers['Content-Type'], 'text/html; charset=utf-8');
|
|
284
|
+
assert.ok(body.includes('PRE-BUILT PRODUCTION HTML'));
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|