melina 1.0.3 → 1.0.5
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/package.json +1 -1
- package/src/client.ts +183 -0
- package/src/createIsland.tsx +21 -23
- package/src/web.ts +14 -13
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -918,6 +918,189 @@ function renderChildren(children: Child | Child[] | undefined, parentFiber: Fibe
|
|
|
918
918
|
}
|
|
919
919
|
}
|
|
920
920
|
|
|
921
|
+
// =============================================================================
|
|
922
|
+
// SERVER-SIDE RENDERING
|
|
923
|
+
// =============================================================================
|
|
924
|
+
|
|
925
|
+
// Void elements that don't have closing tags
|
|
926
|
+
const VOID_ELEMENTS = new Set([
|
|
927
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
928
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
929
|
+
]);
|
|
930
|
+
|
|
931
|
+
// Map of React-style prop names to HTML attribute names
|
|
932
|
+
const PROP_TO_ATTR: Record<string, string> = {
|
|
933
|
+
className: 'class',
|
|
934
|
+
htmlFor: 'for',
|
|
935
|
+
tabIndex: 'tabindex',
|
|
936
|
+
readOnly: 'readonly',
|
|
937
|
+
maxLength: 'maxlength',
|
|
938
|
+
cellPadding: 'cellpadding',
|
|
939
|
+
cellSpacing: 'cellspacing',
|
|
940
|
+
colSpan: 'colspan',
|
|
941
|
+
rowSpan: 'rowspan',
|
|
942
|
+
srcSet: 'srcset',
|
|
943
|
+
useMap: 'usemap',
|
|
944
|
+
frameBorder: 'frameborder',
|
|
945
|
+
contentEditable: 'contenteditable',
|
|
946
|
+
crossOrigin: 'crossorigin',
|
|
947
|
+
dateTime: 'datetime',
|
|
948
|
+
encType: 'enctype',
|
|
949
|
+
formAction: 'formaction',
|
|
950
|
+
formEncType: 'formenctype',
|
|
951
|
+
formMethod: 'formmethod',
|
|
952
|
+
formNoValidate: 'formnovalidate',
|
|
953
|
+
formTarget: 'formtarget',
|
|
954
|
+
hrefLang: 'hreflang',
|
|
955
|
+
inputMode: 'inputmode',
|
|
956
|
+
noValidate: 'novalidate',
|
|
957
|
+
playsInline: 'playsinline',
|
|
958
|
+
autoComplete: 'autocomplete',
|
|
959
|
+
autoFocus: 'autofocus',
|
|
960
|
+
autoPlay: 'autoplay',
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Escape HTML special characters
|
|
965
|
+
*/
|
|
966
|
+
function escapeHtml(str: string): string {
|
|
967
|
+
return str
|
|
968
|
+
.replace(/&/g, '&')
|
|
969
|
+
.replace(/</g, '<')
|
|
970
|
+
.replace(/>/g, '>')
|
|
971
|
+
.replace(/"/g, '"')
|
|
972
|
+
.replace(/'/g, ''');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Convert a VNode tree to an HTML string (Server-Side Rendering)
|
|
977
|
+
* This is melina/client's equivalent to react-dom/server.renderToString()
|
|
978
|
+
*/
|
|
979
|
+
export function renderToString(vnode: VNode | Child): string {
|
|
980
|
+
// Handle null, undefined, booleans
|
|
981
|
+
if (vnode === null || vnode === undefined || vnode === true || vnode === false) {
|
|
982
|
+
return '';
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Handle primitives (strings, numbers)
|
|
986
|
+
if (typeof vnode === 'string') {
|
|
987
|
+
return escapeHtml(vnode);
|
|
988
|
+
}
|
|
989
|
+
if (typeof vnode === 'number') {
|
|
990
|
+
return String(vnode);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Handle arrays
|
|
994
|
+
if (Array.isArray(vnode)) {
|
|
995
|
+
return vnode.map(child => renderToString(child)).join('');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const { type, props } = vnode as VNode;
|
|
999
|
+
|
|
1000
|
+
// Handle Fragment
|
|
1001
|
+
if (type === Fragment) {
|
|
1002
|
+
return renderChildrenToString(props.children);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Handle function components
|
|
1006
|
+
if (typeof type === 'function') {
|
|
1007
|
+
// For SSR, we just call the component function
|
|
1008
|
+
// Hooks will work because we set up currentFiber context
|
|
1009
|
+
const fiber: Fiber = {
|
|
1010
|
+
node: null,
|
|
1011
|
+
vnode: vnode as VNode,
|
|
1012
|
+
hooks: [],
|
|
1013
|
+
hookIndex: 0,
|
|
1014
|
+
parent: null,
|
|
1015
|
+
children: [],
|
|
1016
|
+
cleanup: [],
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const prevFiber = currentFiber;
|
|
1020
|
+
currentFiber = fiber;
|
|
1021
|
+
fiber.hookIndex = 0;
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
const result = (type as Component)(props);
|
|
1025
|
+
currentFiber = prevFiber;
|
|
1026
|
+
return renderToString(result);
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
currentFiber = prevFiber;
|
|
1029
|
+
throw e;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Handle HTML elements
|
|
1034
|
+
const tagName = type as string;
|
|
1035
|
+
let html = `<${tagName}`;
|
|
1036
|
+
|
|
1037
|
+
// Render attributes
|
|
1038
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1039
|
+
if (key === 'children' || key === 'key' || key === 'ref') continue;
|
|
1040
|
+
if (value === undefined || value === null || value === false) continue;
|
|
1041
|
+
|
|
1042
|
+
// Handle dangerouslySetInnerHTML
|
|
1043
|
+
if (key === 'dangerouslySetInnerHTML') continue;
|
|
1044
|
+
|
|
1045
|
+
// Handle style object
|
|
1046
|
+
if (key === 'style' && typeof value === 'object') {
|
|
1047
|
+
const styleStr = Object.entries(value)
|
|
1048
|
+
.map(([k, v]) => {
|
|
1049
|
+
// Convert camelCase to kebab-case
|
|
1050
|
+
const prop = k.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1051
|
+
return `${prop}:${v}`;
|
|
1052
|
+
})
|
|
1053
|
+
.join(';');
|
|
1054
|
+
html += ` style="${escapeHtml(styleStr)}"`;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Skip event handlers on server
|
|
1059
|
+
if (key.startsWith('on') && typeof value === 'function') continue;
|
|
1060
|
+
|
|
1061
|
+
// Convert prop name to attribute name
|
|
1062
|
+
const attrName = PROP_TO_ATTR[key] || key.toLowerCase();
|
|
1063
|
+
|
|
1064
|
+
// Boolean attributes
|
|
1065
|
+
if (value === true) {
|
|
1066
|
+
html += ` ${attrName}`;
|
|
1067
|
+
} else {
|
|
1068
|
+
html += ` ${attrName}="${escapeHtml(String(value))}"`;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
html += '>';
|
|
1073
|
+
|
|
1074
|
+
// Void elements don't have children or closing tags
|
|
1075
|
+
if (VOID_ELEMENTS.has(tagName)) {
|
|
1076
|
+
return html;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Handle dangerouslySetInnerHTML
|
|
1080
|
+
if (props.dangerouslySetInnerHTML) {
|
|
1081
|
+
html += props.dangerouslySetInnerHTML.__html;
|
|
1082
|
+
} else {
|
|
1083
|
+
// Render children
|
|
1084
|
+
html += renderChildrenToString(props.children);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
html += `</${tagName}>`;
|
|
1088
|
+
return html;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Render children to string
|
|
1093
|
+
*/
|
|
1094
|
+
function renderChildrenToString(children: Child | Child[] | undefined): string {
|
|
1095
|
+
if (children === undefined || children === null) return '';
|
|
1096
|
+
|
|
1097
|
+
if (Array.isArray(children)) {
|
|
1098
|
+
return children.map(child => renderToString(child)).join('');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return renderToString(children);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
921
1104
|
// =============================================================================
|
|
922
1105
|
// ISLAND SYSTEM (HANGAR ARCHITECTURE)
|
|
923
1106
|
// =============================================================================
|
package/src/createIsland.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createElement, renderToString, type VNode, type Component, type Child } from './client';
|
|
2
2
|
|
|
3
3
|
// Check if we're on the server
|
|
4
4
|
const isServer = typeof window === 'undefined';
|
|
@@ -19,7 +19,7 @@ declare global {
|
|
|
19
19
|
* ```tsx
|
|
20
20
|
* // components/Counter.tsx
|
|
21
21
|
* 'use client';
|
|
22
|
-
* import { createIsland } from '
|
|
22
|
+
* import { createIsland } from 'melina';
|
|
23
23
|
*
|
|
24
24
|
* function CounterImpl({ initialCount = 0 }) {
|
|
25
25
|
* const [count, setCount] = useState(initialCount);
|
|
@@ -37,21 +37,20 @@ declare global {
|
|
|
37
37
|
* - Hydrates the component with full interactivity
|
|
38
38
|
*/
|
|
39
39
|
export function createIsland<P extends object>(
|
|
40
|
-
|
|
40
|
+
ComponentImpl: Component<P>,
|
|
41
41
|
name: string
|
|
42
|
-
):
|
|
42
|
+
): Component<P> {
|
|
43
43
|
// Return a wrapper component
|
|
44
|
-
const IslandWrapper:
|
|
44
|
+
const IslandWrapper: Component<P> = (props) => {
|
|
45
45
|
if (isServer) {
|
|
46
46
|
// SERVER: Render the island marker
|
|
47
47
|
// We also try to render the component for SEO/initial content
|
|
48
48
|
let innerHtml = '';
|
|
49
49
|
try {
|
|
50
50
|
// Attempt to render for progressive enhancement
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
React.createElement(Component, props)
|
|
51
|
+
// Using melina/client's renderToString
|
|
52
|
+
innerHtml = renderToString(
|
|
53
|
+
createElement(ComponentImpl, props)
|
|
55
54
|
);
|
|
56
55
|
} catch (e) {
|
|
57
56
|
// Component uses hooks - can't SSR, just show placeholder
|
|
@@ -61,18 +60,17 @@ export function createIsland<P extends object>(
|
|
|
61
60
|
// Return the island marker with serialized props
|
|
62
61
|
const propsJson = JSON.stringify(props).replace(/"/g, '"');
|
|
63
62
|
|
|
64
|
-
return
|
|
63
|
+
return createElement('div', {
|
|
65
64
|
'data-melina-island': name,
|
|
66
65
|
'data-props': propsJson,
|
|
67
66
|
dangerouslySetInnerHTML: { __html: innerHtml }
|
|
68
67
|
});
|
|
69
68
|
} else {
|
|
70
69
|
// CLIENT: Render the actual component
|
|
71
|
-
return
|
|
70
|
+
return createElement(ComponentImpl, props);
|
|
72
71
|
}
|
|
73
72
|
};
|
|
74
73
|
|
|
75
|
-
IslandWrapper.displayName = `Island(${name})`;
|
|
76
74
|
return IslandWrapper;
|
|
77
75
|
}
|
|
78
76
|
|
|
@@ -82,7 +80,7 @@ export function createIsland<P extends object>(
|
|
|
82
80
|
* Usage:
|
|
83
81
|
* ```tsx
|
|
84
82
|
* // In a Server Component
|
|
85
|
-
* import { Island } from '
|
|
83
|
+
* import { Island } from 'melina';
|
|
86
84
|
* import { Counter } from './components/Counter';
|
|
87
85
|
*
|
|
88
86
|
* export default function Page() {
|
|
@@ -91,15 +89,15 @@ export function createIsland<P extends object>(
|
|
|
91
89
|
* ```
|
|
92
90
|
*/
|
|
93
91
|
export function Island<P extends object>({
|
|
94
|
-
component:
|
|
92
|
+
component: ComponentImpl,
|
|
95
93
|
name,
|
|
96
94
|
...props
|
|
97
95
|
}: {
|
|
98
|
-
component:
|
|
96
|
+
component: Component<P>;
|
|
99
97
|
name: string;
|
|
100
|
-
} & P):
|
|
101
|
-
const WrappedComponent = createIsland(
|
|
102
|
-
return
|
|
98
|
+
} & P): VNode {
|
|
99
|
+
const WrappedComponent = createIsland(ComponentImpl, name);
|
|
100
|
+
return createElement(WrappedComponent, props as P);
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
/**
|
|
@@ -111,11 +109,11 @@ export function ClientOnly({
|
|
|
111
109
|
children,
|
|
112
110
|
fallback = null
|
|
113
111
|
}: {
|
|
114
|
-
children:
|
|
115
|
-
fallback?:
|
|
116
|
-
}):
|
|
112
|
+
children: Child;
|
|
113
|
+
fallback?: Child;
|
|
114
|
+
}): VNode | null {
|
|
117
115
|
if (isServer) {
|
|
118
|
-
return fallback as
|
|
116
|
+
return fallback as VNode | null;
|
|
119
117
|
}
|
|
120
|
-
return children as
|
|
118
|
+
return children as VNode | null;
|
|
121
119
|
}
|
package/src/web.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { discoverRoutes, matchRoute, type Route, type RouteMatch } from "./route
|
|
|
24
24
|
*/
|
|
25
25
|
const ISLAND_HELPER = `
|
|
26
26
|
// [Melina.js] Auto-injected island wrapper
|
|
27
|
-
import
|
|
27
|
+
import { createElement as __melina_h__ } from 'melina/client';
|
|
28
28
|
const __MELINA_IS_SERVER__ = typeof window === 'undefined' && typeof Bun !== 'undefined';
|
|
29
29
|
function __melina_wrap__(Component, name) {
|
|
30
30
|
if (__MELINA_IS_SERVER__) {
|
|
@@ -34,7 +34,7 @@ function __melina_wrap__(Component, name) {
|
|
|
34
34
|
// Filter out internal props before serializing
|
|
35
35
|
const { _melinaInstance, ...restProps } = props || {};
|
|
36
36
|
const propsJson = JSON.stringify(restProps || {}).replace(/"/g, '"');
|
|
37
|
-
return
|
|
37
|
+
return __melina_h__('div', {
|
|
38
38
|
'data-melina-island': name,
|
|
39
39
|
'data-instance': instanceId,
|
|
40
40
|
'data-props': propsJson,
|
|
@@ -446,6 +446,9 @@ let cachedRuntimePath: string | null = null;
|
|
|
446
446
|
/**
|
|
447
447
|
* Build the Melina client runtime from TypeScript source
|
|
448
448
|
* This bundles src/client.ts and serves it from memory
|
|
449
|
+
*
|
|
450
|
+
* The client runtime is React-free - it uses melina/client's lightweight VDOM.
|
|
451
|
+
* SSR uses React on the server, but the browser only loads melina/client.
|
|
449
452
|
*/
|
|
450
453
|
async function buildRuntime(): Promise<string> {
|
|
451
454
|
// Return cached path if available
|
|
@@ -1200,12 +1203,11 @@ export async function renderPage(options: RenderPageOptions): Promise<string> {
|
|
|
1200
1203
|
//Server-side render the component
|
|
1201
1204
|
let serverHtml = '';
|
|
1202
1205
|
try {
|
|
1203
|
-
// Import
|
|
1204
|
-
const
|
|
1205
|
-
const ReactDOMServer = await import('react-dom/server');
|
|
1206
|
+
// Import melina/client for SSR (React-free)
|
|
1207
|
+
const { createElement, renderToString } = await import('./client');
|
|
1206
1208
|
|
|
1207
|
-
serverHtml =
|
|
1208
|
-
|
|
1209
|
+
serverHtml = renderToString(
|
|
1210
|
+
createElement(Component, { ...props, params })
|
|
1209
1211
|
);
|
|
1210
1212
|
} catch (error) {
|
|
1211
1213
|
console.warn('SSR failed, will use client-side rendering only:', error);
|
|
@@ -1347,12 +1349,11 @@ export function createAppRouter(options: AppRouterOptions = {}): Handler {
|
|
|
1347
1349
|
throw new Error(`No default export found in ${match.route.filePath}`);
|
|
1348
1350
|
}
|
|
1349
1351
|
|
|
1350
|
-
// Import
|
|
1351
|
-
const
|
|
1352
|
-
const ReactDOMServer = await import('react-dom/server');
|
|
1352
|
+
// Import melina/client for SSR (React-free)
|
|
1353
|
+
const { createElement, renderToString } = await import('./client');
|
|
1353
1354
|
|
|
1354
1355
|
// Build the component tree with nested layouts
|
|
1355
|
-
let tree =
|
|
1356
|
+
let tree = createElement(PageComponent, { params: match.params });
|
|
1356
1357
|
|
|
1357
1358
|
// Wrap with layouts (innermost to outermost)
|
|
1358
1359
|
for (let i = match.route.layouts.length - 1; i >= 0; i--) {
|
|
@@ -1361,12 +1362,12 @@ export function createAppRouter(options: AppRouterOptions = {}): Handler {
|
|
|
1361
1362
|
const LayoutComponent = layoutModule.default;
|
|
1362
1363
|
|
|
1363
1364
|
if (LayoutComponent) {
|
|
1364
|
-
tree =
|
|
1365
|
+
tree = createElement(LayoutComponent, { children: tree });
|
|
1365
1366
|
}
|
|
1366
1367
|
}
|
|
1367
1368
|
|
|
1368
1369
|
// Render to HTML
|
|
1369
|
-
const html =
|
|
1370
|
+
const html = renderToString(tree);
|
|
1370
1371
|
|
|
1371
1372
|
// Build CSS
|
|
1372
1373
|
let stylesVirtualPath = '';
|