@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,122 @@
|
|
|
1
|
+
import * as esbuild from 'esbuild';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { IslandInstance } from './island-registry.js';
|
|
7
|
+
|
|
8
|
+
export interface BundlerOptions {
|
|
9
|
+
outDir: string;
|
|
10
|
+
minify?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function bundleIslands(
|
|
14
|
+
islands: IslandInstance[],
|
|
15
|
+
options: BundlerOptions
|
|
16
|
+
): Promise<string[]> {
|
|
17
|
+
const { outDir, minify = true } = options;
|
|
18
|
+
const islandsOutputDir = path.join(outDir, 'assets', 'islands');
|
|
19
|
+
await fs.mkdir(islandsOutputDir, { recursive: true });
|
|
20
|
+
|
|
21
|
+
const tempDir = path.resolve(process.cwd(), '.react-html/temp-islands');
|
|
22
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
const uniqueIslandsMap = new Map<string, IslandInstance>();
|
|
25
|
+
for (const island of islands) {
|
|
26
|
+
if (!uniqueIslandsMap.has(island.name)) {
|
|
27
|
+
uniqueIslandsMap.set(island.name, island);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bundledFiles: string[] = [];
|
|
32
|
+
|
|
33
|
+
// Locate hydrate module
|
|
34
|
+
const currentDir = import.meta.dirname || path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
let hydratePath = path.resolve(currentDir, '../client/hydrate.js');
|
|
36
|
+
if (!existsSync(hydratePath)) {
|
|
37
|
+
hydratePath = path.resolve(currentDir, '../client/hydrate.ts');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let islandClientPath = path.resolve(currentDir, './island-client.js');
|
|
41
|
+
if (!existsSync(islandClientPath)) {
|
|
42
|
+
islandClientPath = path.resolve(currentDir, './island-client.ts');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [name, island] of uniqueIslandsMap.entries()) {
|
|
46
|
+
let resolvedEntry = island.entryPath;
|
|
47
|
+
if (resolvedEntry.startsWith('file://')) {
|
|
48
|
+
resolvedEntry = fileURLToPath(resolvedEntry);
|
|
49
|
+
} else {
|
|
50
|
+
resolvedEntry = path.resolve(resolvedEntry);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(resolvedEntry);
|
|
55
|
+
} catch {
|
|
56
|
+
console.warn(`[react-html] Warning: Island source file not found for "${name}" at "${resolvedEntry}". Skipping client bundle.`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Create a virtual client entrypoint for this island
|
|
61
|
+
const virtualEntryFile = path.join(tempDir, `${name}.entry.ts`);
|
|
62
|
+
const virtualEntryCode = `
|
|
63
|
+
import React from 'react';
|
|
64
|
+
import { hydrateIsland } from ${JSON.stringify(hydratePath)};
|
|
65
|
+
import * as Module from ${JSON.stringify(resolvedEntry)};
|
|
66
|
+
|
|
67
|
+
const raw = Module.default || Module[${JSON.stringify(name)}] || Module;
|
|
68
|
+
const Component = (raw && raw.__rawComponent) ? raw.__rawComponent : raw;
|
|
69
|
+
|
|
70
|
+
hydrateIsland(${JSON.stringify(name)}, Component);
|
|
71
|
+
`;
|
|
72
|
+
await fs.writeFile(virtualEntryFile, virtualEntryCode, 'utf8');
|
|
73
|
+
|
|
74
|
+
const outfile = path.join(islandsOutputDir, `${name}.js`);
|
|
75
|
+
|
|
76
|
+
await esbuild.build({
|
|
77
|
+
entryPoints: [virtualEntryFile],
|
|
78
|
+
outfile,
|
|
79
|
+
bundle: true,
|
|
80
|
+
format: 'esm',
|
|
81
|
+
platform: 'browser',
|
|
82
|
+
target: ['es2022'],
|
|
83
|
+
minify,
|
|
84
|
+
treeShaking: true,
|
|
85
|
+
define: {
|
|
86
|
+
'process.env.NODE_ENV': '"production"'
|
|
87
|
+
},
|
|
88
|
+
alias: {
|
|
89
|
+
'react-html': islandClientPath
|
|
90
|
+
},
|
|
91
|
+
plugins: [
|
|
92
|
+
{
|
|
93
|
+
name: 'redirect-react-html-imports',
|
|
94
|
+
setup(build) {
|
|
95
|
+
// Redirect any import of react-html or root index to island-client
|
|
96
|
+
build.onResolve({ filter: /(react-html|(\/|\\)(src|dist)(\/|\\)index(\.js|\.mjs|\.ts)?)$/ }, () => {
|
|
97
|
+
return { path: islandClientPath };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
loader: {
|
|
103
|
+
'.tsx': 'tsx',
|
|
104
|
+
'.ts': 'ts',
|
|
105
|
+
'.jsx': 'jsx',
|
|
106
|
+
'.js': 'js',
|
|
107
|
+
'.css': 'empty'
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
bundledFiles.push(outfile);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Cleanup virtual entry files
|
|
115
|
+
try {
|
|
116
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore cleanup error
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return bundledFiles;
|
|
122
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust HTML formatter to output clean, readable HTML
|
|
3
|
+
* Preserves exact inner markup inside reactive islands to ensure seamless React hydration.
|
|
4
|
+
*/
|
|
5
|
+
export function formatHtml(html: string): string {
|
|
6
|
+
// Extract and preserve island contents verbatim
|
|
7
|
+
const preservedIslands: string[] = [];
|
|
8
|
+
let clean = html.replace(
|
|
9
|
+
/<!--__ISLAND_START__-->([\s\S]*?)<!--__ISLAND_END__-->/g,
|
|
10
|
+
(_, inner) => {
|
|
11
|
+
const idx = preservedIslands.length;
|
|
12
|
+
preservedIslands.push(inner);
|
|
13
|
+
return `%%REACT_HTML_ISLAND_${idx}%%`;
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Strip React comment artifacts outside islands if any
|
|
18
|
+
clean = clean.replace(/<!-- -->/g, '');
|
|
19
|
+
|
|
20
|
+
const tokens = clean.split(/(<[^>]+>)/g).filter((t) => t.trim().length > 0);
|
|
21
|
+
let formatted = '';
|
|
22
|
+
let indent = 0;
|
|
23
|
+
const indentStr = ' ';
|
|
24
|
+
|
|
25
|
+
// Void tags that don't need closing or indentation changes
|
|
26
|
+
const voidTags = new Set([
|
|
27
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
28
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr', '!doctype'
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// Tags where content is typically kept on the same line if brief
|
|
32
|
+
const inlineTags = new Set([
|
|
33
|
+
'title', 'span', 'strong', 'em', 'b', 'i', 'code', 'small', 'sub', 'sup'
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
37
|
+
const token = tokens[i].trim();
|
|
38
|
+
|
|
39
|
+
if (token.startsWith('<!DOCTYPE') || token.startsWith('<!doctype')) {
|
|
40
|
+
formatted += token + '\n';
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (token.startsWith('</')) {
|
|
45
|
+
// Closing tag
|
|
46
|
+
const match = token.match(/<\/([a-zA-Z0-9\-]+)/);
|
|
47
|
+
const tagName = match ? match[1].toLowerCase() : '';
|
|
48
|
+
|
|
49
|
+
indent = Math.max(0, indent - 1);
|
|
50
|
+
formatted += indentStr.repeat(indent) + token + '\n';
|
|
51
|
+
} else if (token.startsWith('<') && !token.startsWith('<!--')) {
|
|
52
|
+
// Opening or self-closing tag
|
|
53
|
+
const match = token.match(/<([a-zA-Z0-9\-]+)/);
|
|
54
|
+
const tagName = match ? match[1].toLowerCase() : '';
|
|
55
|
+
const isSelfClosing = token.endsWith('/>') || voidTags.has(tagName);
|
|
56
|
+
|
|
57
|
+
// Check if next token is text and next-next token is closing tag
|
|
58
|
+
if (
|
|
59
|
+
inlineTags.has(tagName) &&
|
|
60
|
+
i + 2 < tokens.length &&
|
|
61
|
+
!tokens[i + 1].trim().startsWith('<') &&
|
|
62
|
+
tokens[i + 2].trim().toLowerCase() === `</${tagName}>`
|
|
63
|
+
) {
|
|
64
|
+
const text = tokens[i + 1].trim();
|
|
65
|
+
const closeTag = tokens[i + 2].trim();
|
|
66
|
+
formatted += indentStr.repeat(indent) + `${token}${text}${closeTag}\n`;
|
|
67
|
+
i += 2;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
formatted += indentStr.repeat(indent) + token + '\n';
|
|
72
|
+
|
|
73
|
+
if (!isSelfClosing) {
|
|
74
|
+
indent++;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Standalone text content
|
|
78
|
+
formatted += indentStr.repeat(indent) + token + '\n';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Restore preserved island markup untouched
|
|
83
|
+
let finalHtml = formatted.replace(
|
|
84
|
+
/%%REACT_HTML_ISLAND_(\d+)%%/g,
|
|
85
|
+
(_, idxStr) => {
|
|
86
|
+
const idx = parseInt(idxStr, 10);
|
|
87
|
+
return preservedIslands[idx] ?? '';
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return finalHtml.trim() + '\n';
|
|
92
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React, { ComponentType, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export function Island<P extends Record<string, unknown>>({
|
|
4
|
+
component: Component,
|
|
5
|
+
props = {} as P,
|
|
6
|
+
children
|
|
7
|
+
}: {
|
|
8
|
+
component: ComponentType<P>;
|
|
9
|
+
props?: P;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}): React.ReactElement {
|
|
13
|
+
return React.createElement(Component, props, children);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function island<P extends Record<string, unknown>>(
|
|
17
|
+
Component: ComponentType<P>,
|
|
18
|
+
_sourcePath?: string,
|
|
19
|
+
_customName?: string
|
|
20
|
+
): ComponentType<P> {
|
|
21
|
+
return Component;
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
export interface IslandInstance {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
entryPath: string;
|
|
7
|
+
props: Record<string, unknown>;
|
|
8
|
+
clientDirective?: 'load' | 'idle' | 'visible';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RenderContext {
|
|
12
|
+
pagePath: string;
|
|
13
|
+
islands: IslandInstance[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const GLOBAL_STORAGE_KEY = Symbol.for('__react_html_render_context__');
|
|
17
|
+
|
|
18
|
+
const storage: AsyncLocalStorage<RenderContext> =
|
|
19
|
+
(globalThis as any)[GLOBAL_STORAGE_KEY] ||
|
|
20
|
+
((globalThis as any)[GLOBAL_STORAGE_KEY] = new AsyncLocalStorage<RenderContext>());
|
|
21
|
+
|
|
22
|
+
export function runWithRenderContext<T>(context: RenderContext, fn: () => T): T {
|
|
23
|
+
return storage.run(context, fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getCurrentRenderContext(): RenderContext | undefined {
|
|
27
|
+
return storage.getStore();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerIslandInContext(island: IslandInstance): void {
|
|
31
|
+
const ctx = storage.getStore();
|
|
32
|
+
if (ctx) {
|
|
33
|
+
ctx.islands.push(island);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { registerIslandInContext } from './island-registry.js';
|
|
4
|
+
|
|
5
|
+
let islandCounter = 0;
|
|
6
|
+
|
|
7
|
+
export interface IslandProps<P = Record<string, unknown>> {
|
|
8
|
+
/** Explicit island name (optional) */
|
|
9
|
+
name?: string;
|
|
10
|
+
/** Path to the island component file (relative or absolute) */
|
|
11
|
+
src?: string;
|
|
12
|
+
/** The React component to render */
|
|
13
|
+
component: ComponentType<P>;
|
|
14
|
+
/** Props passed to the component */
|
|
15
|
+
props?: P;
|
|
16
|
+
/** Client hydration directive (default: 'load') */
|
|
17
|
+
client?: 'load' | 'idle' | 'visible';
|
|
18
|
+
/** Optional wrapper tag, default: 'div' */
|
|
19
|
+
as?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Island<P extends Record<string, unknown>>({
|
|
26
|
+
name,
|
|
27
|
+
src,
|
|
28
|
+
component: Component,
|
|
29
|
+
props = {} as P,
|
|
30
|
+
client = 'load',
|
|
31
|
+
as: Tag = 'div',
|
|
32
|
+
className,
|
|
33
|
+
style,
|
|
34
|
+
children
|
|
35
|
+
}: IslandProps<P>): React.ReactElement {
|
|
36
|
+
const componentName =
|
|
37
|
+
name ||
|
|
38
|
+
(Component as any).__islandName ||
|
|
39
|
+
Component.displayName ||
|
|
40
|
+
Component.name ||
|
|
41
|
+
'AnonymousIsland';
|
|
42
|
+
|
|
43
|
+
const islandId = `island-${++islandCounter}`;
|
|
44
|
+
|
|
45
|
+
// If src is specified or attached to component
|
|
46
|
+
const entryPath = src || (Component as any).__islandPath || '';
|
|
47
|
+
|
|
48
|
+
registerIslandInContext({
|
|
49
|
+
id: islandId,
|
|
50
|
+
name: componentName,
|
|
51
|
+
entryPath,
|
|
52
|
+
props,
|
|
53
|
+
clientDirective: client
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const Wrapper = Tag as any;
|
|
57
|
+
|
|
58
|
+
// Use renderToString for the island's inner tree to produce hydration-compatible SSR markup
|
|
59
|
+
const innerHtml = renderToString(React.createElement(Component, props, children));
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Wrapper
|
|
63
|
+
data-island={componentName}
|
|
64
|
+
data-island-id={islandId}
|
|
65
|
+
data-props={JSON.stringify(props)}
|
|
66
|
+
data-client={client}
|
|
67
|
+
className={className}
|
|
68
|
+
style={style}
|
|
69
|
+
dangerouslySetInnerHTML={{ __html: `<!--__ISLAND_START__-->${innerHtml}<!--__ISLAND_END__-->` }}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Helper to wrap any React component into a ready-to-use Island component
|
|
76
|
+
* Example:
|
|
77
|
+
* export const Counter = island(CounterComponent, import.meta.url, 'Counter');
|
|
78
|
+
* // In page: <Counter initialCount={5} />
|
|
79
|
+
*/
|
|
80
|
+
export function island<P extends Record<string, unknown>>(
|
|
81
|
+
Component: ComponentType<P>,
|
|
82
|
+
sourcePath: string,
|
|
83
|
+
customName?: string
|
|
84
|
+
): ComponentType<P & { client?: 'load' | 'idle' | 'visible'; as?: string; className?: string; style?: React.CSSProperties }> {
|
|
85
|
+
const resolvedName = customName || Component.displayName || Component.name || 'AnonymousIsland';
|
|
86
|
+
(Component as any).__islandPath = sourcePath;
|
|
87
|
+
(Component as any).__islandName = resolvedName;
|
|
88
|
+
|
|
89
|
+
const IslandWrapper = (props: P & { client?: 'load' | 'idle' | 'visible'; as?: string; className?: string; style?: React.CSSProperties }) => {
|
|
90
|
+
const { client, as, className, style, ...componentProps } = props;
|
|
91
|
+
return (
|
|
92
|
+
<Island
|
|
93
|
+
name={resolvedName}
|
|
94
|
+
src={sourcePath}
|
|
95
|
+
component={Component}
|
|
96
|
+
props={componentProps as P}
|
|
97
|
+
client={client}
|
|
98
|
+
as={as}
|
|
99
|
+
className={className}
|
|
100
|
+
style={style}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
IslandWrapper.displayName = `Island(${resolvedName})`;
|
|
106
|
+
(IslandWrapper as any).__islandPath = sourcePath;
|
|
107
|
+
(IslandWrapper as any).__islandName = resolvedName;
|
|
108
|
+
(IslandWrapper as any).__rawComponent = Component;
|
|
109
|
+
return IslandWrapper;
|
|
110
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
|