ezfw-core 1.0.40 → 1.0.41

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.
@@ -66,9 +66,9 @@ export declare class StaticHtmlRenderer {
66
66
  /**
67
67
  * Render an island as a placeholder with hydration data
68
68
  *
69
- * Islands are NOT pre-rendered because they use reactive state,
70
- * computed properties, and methods that require proper `this` binding.
71
- * Instead, we render a placeholder that will be hydrated on the client.
69
+ * Islands are pre-rendered as static HTML so search engines can index the content.
70
+ * The client will then hydrate to add interactivity (event handlers, reactive state).
71
+ * This approach gives us the best of both worlds: SEO + interactivity.
72
72
  */
73
73
  private renderIslandPlaceholder;
74
74
  /**
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Ez Islands - Static HTML Renderer
3
+ *
4
+ * Renders component trees to HTML strings for SSR/SSG.
5
+ * Static components are fully rendered to HTML.
6
+ * Islands are rendered as placeholders with hydration data.
7
+ */
8
+ import { analyzer } from './analyzer.js';
9
+ // HTML void elements (self-closing)
10
+ const VOID_ELEMENTS = new Set([
11
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
12
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
13
+ ]);
14
+ // Attributes that should be rendered as boolean
15
+ const BOOLEAN_ATTRS = new Set([
16
+ 'checked', 'disabled', 'readonly', 'required', 'autofocus',
17
+ 'multiple', 'hidden', 'selected', 'open', 'novalidate'
18
+ ]);
19
+ // Attributes to skip (handled specially or internal)
20
+ const SKIP_ATTRS = new Set([
21
+ 'eztype', 'items', 'template', 'props', 'bind', 'controller',
22
+ 'load', 'state', 'island', 'static', 'onMount', 'onVisible',
23
+ 'onDestroy', 'css', '_ezAfterRender', '_styleModule'
24
+ ]);
25
+ // Event attributes to skip in static render
26
+ const EVENT_ATTRS = new Set([
27
+ 'onClick', 'onChange', 'onInput', 'onSubmit', 'onFocus', 'onBlur',
28
+ 'onMouseEnter', 'onMouseLeave', 'onKeyDown', 'onKeyUp'
29
+ ]);
30
+ let islandIdCounter = 0;
31
+ /**
32
+ * Main static renderer class
33
+ */
34
+ export class StaticHtmlRenderer {
35
+ analyzer;
36
+ constructor() {
37
+ this.analyzer = analyzer;
38
+ }
39
+ /**
40
+ * Render a page component to full HTML document
41
+ */
42
+ async renderPage(pageName, pageDefinition, context = {}) {
43
+ const ctx = {
44
+ registry: context.registry || new Map(),
45
+ styles: context.styles || new Set(),
46
+ islands: context.islands || [],
47
+ props: context.props || {},
48
+ serverData: context.serverData || {}
49
+ };
50
+ // Register the page component
51
+ ctx.registry.set(pageName, pageDefinition);
52
+ // Execute server-side load() if present
53
+ if (pageDefinition.load) {
54
+ const loadContext = {
55
+ params: ctx.props || {},
56
+ url: '/'
57
+ };
58
+ ctx.serverData = await pageDefinition.load(loadContext);
59
+ }
60
+ // Render the component tree
61
+ const bodyHtml = await this.renderComponent(pageName, ctx, ctx.serverData);
62
+ // Note: Hydration script is generated by ViteIslandsPlugin
63
+ // The plugin reads ctx.islands and generates the script
64
+ return bodyHtml;
65
+ }
66
+ /**
67
+ * Render a single component to HTML string
68
+ */
69
+ async renderComponent(name, ctx, props = {}) {
70
+ const definition = ctx.registry.get(name);
71
+ if (!definition) {
72
+ // Try to render as native HTML element
73
+ return this.renderNativeElement(name, props, ctx);
74
+ }
75
+ // Analyze the component
76
+ const analysis = this.analyzer.analyze(name, definition);
77
+ if (analysis.type === 'island') {
78
+ // Render as island placeholder
79
+ return this.renderIslandPlaceholder(name, definition, props, ctx);
80
+ }
81
+ // Render as static HTML
82
+ return this.renderStaticComponent(name, definition, props, ctx);
83
+ }
84
+ /**
85
+ * Render a static component to HTML
86
+ */
87
+ async renderStaticComponent(name, definition, props, ctx) {
88
+ let config;
89
+ // Get component config from template or items
90
+ if (definition.template) {
91
+ // Get controller state for SSR
92
+ let mockCtrl = null;
93
+ // Try to get controller from ez shim (set up by ViteIslandsPlugin)
94
+ const ez = globalThis.ez;
95
+ if (definition.controller && ez && ez.getControllerSync) {
96
+ mockCtrl = ez.getControllerSync(definition.controller);
97
+ }
98
+ else if (definition.state) {
99
+ // Fallback to component's own state
100
+ mockCtrl = { state: { ...definition.state } };
101
+ }
102
+ config = definition.template(props, mockCtrl, mockCtrl ? mockCtrl.state : null);
103
+ }
104
+ else if (definition.items) {
105
+ config = { items: definition.items };
106
+ }
107
+ else {
108
+ config = {};
109
+ }
110
+ // Merge definition-level properties
111
+ if (definition.cls) {
112
+ config.cls = this.mergeCls(config.cls, definition.cls);
113
+ }
114
+ if (definition.style) {
115
+ config.style = { ...definition.style, ...(config.style || {}) };
116
+ }
117
+ // Render the config tree
118
+ return this.renderConfig(config, ctx);
119
+ }
120
+ /**
121
+ * Render an island as a placeholder with hydration data
122
+ *
123
+ * Islands are pre-rendered as static HTML so search engines can index the content.
124
+ * The client will then hydrate to add interactivity (event handlers, reactive state).
125
+ * This approach gives us the best of both worlds: SEO + interactivity.
126
+ */
127
+ async renderIslandPlaceholder(name, definition, props, ctx) {
128
+ const islandId = `ez-island-${++islandIdCounter}`;
129
+ // Store island data for hydration
130
+ const islandData = {
131
+ id: islandId,
132
+ component: name,
133
+ props: this.serializableProps(props)
134
+ };
135
+ ctx.islands.push(islandData);
136
+ // Render the full static content (for SEO)
137
+ const staticContent = await this.renderStaticComponent(name, definition, props, ctx);
138
+ // Wrap in island container with data attributes for hydration
139
+ const propsJson = this.escapeAttr(JSON.stringify(islandData.props));
140
+ return `<div data-ez-island="${name}" data-ez-island-id="${islandId}" data-ez-props="${propsJson}">${staticContent}</div>`;
141
+ }
142
+ /**
143
+ * Render a config object to HTML
144
+ */
145
+ async renderConfig(config, ctx) {
146
+ if (!config)
147
+ return '';
148
+ const eztype = config.eztype || 'div';
149
+ // Check if it's a registered component
150
+ if (ctx.registry.has(eztype)) {
151
+ return this.renderComponent(eztype, ctx, config.props || {});
152
+ }
153
+ // Check if eztype starts with uppercase (custom component)
154
+ if (eztype[0] === eztype[0].toUpperCase() && eztype !== 'EzComponent') {
155
+ // Try to find in registry, otherwise treat as unknown
156
+ if (!ctx.registry.has(eztype)) {
157
+ console.warn(`Unknown component: ${eztype}`);
158
+ return `<!-- Unknown: ${eztype} -->`;
159
+ }
160
+ }
161
+ // Render as native HTML element
162
+ return this.renderNativeElement(eztype, config, ctx);
163
+ }
164
+ /**
165
+ * Render a native HTML element
166
+ */
167
+ async renderNativeElement(tag, config, ctx) {
168
+ // Handle EzComponent as div
169
+ const actualTag = tag === 'EzComponent' ? 'div' : tag.toLowerCase();
170
+ // Build attributes
171
+ const attrs = this.buildAttributes(config);
172
+ // Handle void elements
173
+ if (VOID_ELEMENTS.has(actualTag)) {
174
+ return `<${actualTag}${attrs}>`;
175
+ }
176
+ // Build children
177
+ let children = '';
178
+ if (config.text) {
179
+ children = this.escapeHtml(config.text);
180
+ }
181
+ else if (config.html) {
182
+ children = config.html; // Raw HTML, use with caution
183
+ }
184
+ else if (config.items && Array.isArray(config.items)) {
185
+ const childHtmls = await Promise.all(config.items
186
+ .filter(item => item != null)
187
+ .map(item => this.renderConfig(item, ctx)));
188
+ children = childHtmls.join('');
189
+ }
190
+ return `<${actualTag}${attrs}>${children}</${actualTag}>`;
191
+ }
192
+ /**
193
+ * Build HTML attributes string from config
194
+ */
195
+ buildAttributes(config) {
196
+ const parts = [];
197
+ // Handle class
198
+ if (config.cls) {
199
+ const classes = Array.isArray(config.cls)
200
+ ? config.cls.join(' ')
201
+ : config.cls;
202
+ if (classes) {
203
+ parts.push(`class="${this.escapeAttr(classes)}"`);
204
+ }
205
+ }
206
+ // Handle style
207
+ if (config.style && typeof config.style === 'object') {
208
+ const styleStr = Object.entries(config.style)
209
+ .map(([key, value]) => {
210
+ // Convert camelCase to kebab-case
211
+ const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
212
+ return `${cssKey}: ${value}`;
213
+ })
214
+ .join('; ');
215
+ if (styleStr) {
216
+ parts.push(`style="${this.escapeAttr(styleStr)}"`);
217
+ }
218
+ }
219
+ // Handle other attributes
220
+ for (const [key, value] of Object.entries(config)) {
221
+ // Skip special attributes
222
+ if (SKIP_ATTRS.has(key) || EVENT_ATTRS.has(key))
223
+ continue;
224
+ if (key === 'cls' || key === 'style' || key === 'text' || key === 'html')
225
+ continue;
226
+ // Handle boolean attributes
227
+ if (BOOLEAN_ATTRS.has(key)) {
228
+ if (value) {
229
+ parts.push(key);
230
+ }
231
+ continue;
232
+ }
233
+ // Handle regular attributes
234
+ if (value !== undefined && value !== null && typeof value !== 'function') {
235
+ // Convert camelCase to kebab-case for certain attrs
236
+ const attrName = this.toAttrName(key);
237
+ parts.push(`${attrName}="${this.escapeAttr(String(value))}"`);
238
+ }
239
+ }
240
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
241
+ }
242
+ /**
243
+ * Generate hydration script for islands
244
+ */
245
+ generateHydrationScript(islands) {
246
+ if (islands.length === 0)
247
+ return '';
248
+ const islandsJson = JSON.stringify(islands);
249
+ return `
250
+ <script type="module">
251
+ import { hydrateIslands } from '/ez/islands/runtime.js';
252
+ window.__EZ_ISLANDS__ = ${islandsJson};
253
+ hydrateIslands(window.__EZ_ISLANDS__);
254
+ </script>`;
255
+ }
256
+ /**
257
+ * Convert camelCase to kebab-case for attribute names
258
+ */
259
+ toAttrName(key) {
260
+ // Special cases
261
+ if (key === 'className')
262
+ return 'class';
263
+ if (key === 'htmlFor')
264
+ return 'for';
265
+ if (key === 'tabIndex')
266
+ return 'tabindex';
267
+ if (key === 'maxLength')
268
+ return 'maxlength';
269
+ if (key === 'minLength')
270
+ return 'minlength';
271
+ // Keep data-* and aria-* as-is
272
+ if (key.startsWith('data') || key.startsWith('aria')) {
273
+ return key.replace(/([A-Z])/g, '-$1').toLowerCase();
274
+ }
275
+ return key.toLowerCase();
276
+ }
277
+ /**
278
+ * Escape HTML special characters
279
+ */
280
+ escapeHtml(str) {
281
+ return str
282
+ .replace(/&/g, '&amp;')
283
+ .replace(/</g, '&lt;')
284
+ .replace(/>/g, '&gt;');
285
+ }
286
+ /**
287
+ * Escape attribute value
288
+ */
289
+ escapeAttr(str) {
290
+ return str
291
+ .replace(/&/g, '&amp;')
292
+ .replace(/"/g, '&quot;')
293
+ .replace(/'/g, '&#39;')
294
+ .replace(/</g, '&lt;')
295
+ .replace(/>/g, '&gt;');
296
+ }
297
+ /**
298
+ * Merge class values
299
+ */
300
+ mergeCls(a, b) {
301
+ const classes = [];
302
+ if (a) {
303
+ classes.push(...(Array.isArray(a) ? a : [a]));
304
+ }
305
+ if (b) {
306
+ classes.push(...(Array.isArray(b) ? b : [b]));
307
+ }
308
+ return classes.join(' ');
309
+ }
310
+ /**
311
+ * Make props serializable (remove functions, etc.)
312
+ */
313
+ serializableProps(props) {
314
+ const result = {};
315
+ for (const [key, value] of Object.entries(props)) {
316
+ if (typeof value === 'function')
317
+ continue;
318
+ if (typeof value === 'object' && value !== null) {
319
+ result[key] = this.serializableProps(value);
320
+ }
321
+ else {
322
+ result[key] = value;
323
+ }
324
+ }
325
+ return result;
326
+ }
327
+ /**
328
+ * Reset island counter (for testing)
329
+ */
330
+ resetIslandCounter() {
331
+ islandIdCounter = 0;
332
+ }
333
+ }
334
+ // Export singleton
335
+ export const staticHtmlRenderer = new StaticHtmlRenderer();
336
+ /**
337
+ * Convenience function to render a component to HTML string
338
+ */
339
+ export async function renderToString(name, definition, props = {}, registry) {
340
+ const ctx = {
341
+ registry: registry || new Map([[name, definition]]),
342
+ styles: new Set(),
343
+ islands: [],
344
+ props
345
+ };
346
+ return staticHtmlRenderer.renderComponent(name, ctx, props);
347
+ }