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.
- package/islands/StaticHtmlRenderer.d.ts +3 -3
- package/islands/StaticHtmlRenderer.js +347 -0
- package/islands/ViteIslandsPlugin.js +792 -0
- package/islands/analyzer.js +362 -0
- package/islands/runtime.js +387 -0
- package/package.json +1 -1
|
@@ -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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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, '&')
|
|
283
|
+
.replace(/</g, '<')
|
|
284
|
+
.replace(/>/g, '>');
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Escape attribute value
|
|
288
|
+
*/
|
|
289
|
+
escapeAttr(str) {
|
|
290
|
+
return str
|
|
291
|
+
.replace(/&/g, '&')
|
|
292
|
+
.replace(/"/g, '"')
|
|
293
|
+
.replace(/'/g, ''')
|
|
294
|
+
.replace(/</g, '<')
|
|
295
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|