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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melina",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "A lightweight, islands-architecture web framework for Bun with Next.js-style routing.",
5
5
  "module": "./src/web.ts",
6
6
  "main": "./src/web.ts",
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, '&amp;')
969
+ .replace(/</g, '&lt;')
970
+ .replace(/>/g, '&gt;')
971
+ .replace(/"/g, '&quot;')
972
+ .replace(/'/g, '&#39;');
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
  // =============================================================================
@@ -1,4 +1,4 @@
1
- import React from 'react';
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 '@ments/web';
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
- Component: React.ComponentType<P>,
40
+ ComponentImpl: Component<P>,
41
41
  name: string
42
- ): React.FC<P> {
42
+ ): Component<P> {
43
43
  // Return a wrapper component
44
- const IslandWrapper: React.FC<P> = (props) => {
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
- // This might fail if the component uses hooks
52
- const ReactDOMServer = require('react-dom/server');
53
- innerHtml = ReactDOMServer.renderToString(
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, '&quot;');
63
62
 
64
- return React.createElement('div', {
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 React.createElement(Component, props);
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 '@ments/web';
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: Component,
92
+ component: ComponentImpl,
95
93
  name,
96
94
  ...props
97
95
  }: {
98
- component: React.ComponentType<P>;
96
+ component: Component<P>;
99
97
  name: string;
100
- } & P): React.ReactElement {
101
- const WrappedComponent = createIsland(Component, name);
102
- return React.createElement(WrappedComponent, props as P);
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: React.ReactNode;
115
- fallback?: React.ReactNode;
116
- }): React.ReactElement | null {
112
+ children: Child;
113
+ fallback?: Child;
114
+ }): VNode | null {
117
115
  if (isServer) {
118
- return fallback as React.ReactElement | null;
116
+ return fallback as VNode | null;
119
117
  }
120
- return children as React.ReactElement;
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 * as __React__ from 'react';
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, '&quot;');
37
- return __React__.createElement('div', {
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 React for SSR
1204
- const React = await import('react');
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 = ReactDOMServer.renderToString(
1208
- React.createElement(Component, { ...props, params })
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 React for SSR
1351
- const React = await import('react');
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 = React.createElement(PageComponent, { params: match.params });
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 = React.createElement(LayoutComponent, { children: tree });
1365
+ tree = createElement(LayoutComponent, { children: tree });
1365
1366
  }
1366
1367
  }
1367
1368
 
1368
1369
  // Render to HTML
1369
- const html = ReactDOMServer.renderToString(tree);
1370
+ const html = renderToString(tree);
1370
1371
 
1371
1372
  // Build CSS
1372
1373
  let stylesVirtualPath = '';