cumstack 1.0.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/LICENSE +21 -0
- package/README.md +3 -0
- package/cli/build.js +19 -0
- package/cli/builder.js +172 -0
- package/cli/create.js +36 -0
- package/cli/dev.js +109 -0
- package/cli/index.js +65 -0
- package/index.js +22 -0
- package/package.json +67 -0
- package/src/app/client/Twink.js +57 -0
- package/src/app/client/components.js +28 -0
- package/src/app/client/hmr.js +161 -0
- package/src/app/client/index.js +144 -0
- package/src/app/client.js +599 -0
- package/src/app/index.js +8 -0
- package/src/app/server/hono-utils.js +292 -0
- package/src/app/server/index.js +457 -0
- package/src/app/server/jsx.js +168 -0
- package/src/app/server.js +373 -0
- package/src/app/shared/i18n.js +271 -0
- package/src/app/shared/language-codes.js +199 -0
- package/src/app/shared/reactivity.js +259 -0
- package/src/app/shared/router.js +153 -0
- package/src/app/shared/utils.js +127 -0
- package/templates/monorepo/README.md +27 -0
- package/templates/monorepo/api/package.json +13 -0
- package/templates/monorepo/app/package.json +19 -0
- package/templates/monorepo/app/src/entry.client.jsx +4 -0
- package/templates/monorepo/app/src/entry.server.jsx +14 -0
- package/templates/monorepo/app/src/main.css +7 -0
- package/templates/monorepo/app/src/pages/404.jsx +9 -0
- package/templates/monorepo/app/src/pages/Home.jsx +8 -0
- package/templates/monorepo/app/wrangler.toml +35 -0
- package/templates/monorepo/package.json +18 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack Server
|
|
3
|
+
* server-side rendering with hono
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { setLanguage, extractLanguageFromRoute } from '../shared/i18n.js';
|
|
8
|
+
import { raw } from 'hono/html';
|
|
9
|
+
|
|
10
|
+
const routeRegistry = new Map();
|
|
11
|
+
let globalI18nConfig = null;
|
|
12
|
+
let honoApp = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* render to string (ssr) - convert JSX to HTML string
|
|
16
|
+
* Handles both Hono JSX nodes and cumstack's plain object format
|
|
17
|
+
*/
|
|
18
|
+
export async function renderToString(vnode) {
|
|
19
|
+
if (vnode === null || vnode === undefined) return '';
|
|
20
|
+
if (typeof vnode === 'string') return vnode;
|
|
21
|
+
if (typeof vnode === 'number') return String(vnode);
|
|
22
|
+
if (typeof vnode === 'boolean') return '';
|
|
23
|
+
|
|
24
|
+
// Handle Hono HTML strings (String objects with isEscaped property)
|
|
25
|
+
// These are the pre-rendered strings that Hono JSX produces
|
|
26
|
+
if (vnode && typeof vnode === 'object' && vnode instanceof String) {
|
|
27
|
+
const htmlString = vnode.toString();
|
|
28
|
+
// Convert className to class since Hono doesn't do this
|
|
29
|
+
const fixedHtml = htmlString.replace(/\sclassName=/g, ' class=');
|
|
30
|
+
return fixedHtml;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Handle arrays
|
|
34
|
+
if (Array.isArray(vnode)) {
|
|
35
|
+
const results = await Promise.all(vnode.map((child) => renderToString(child)));
|
|
36
|
+
return results.join('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// For plain objects from cumstack components (like Twink)
|
|
40
|
+
if (vnode && typeof vnode === 'object' && vnode.type && vnode.props !== undefined && !vnode.toStringToBuffer) {
|
|
41
|
+
const { type, props, children } = vnode;
|
|
42
|
+
|
|
43
|
+
// Handle function components - call them
|
|
44
|
+
if (typeof type === 'function') {
|
|
45
|
+
const result = type({ ...props, children });
|
|
46
|
+
return await renderToString(result);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Render as HTML element
|
|
50
|
+
const attrs = Object.entries(props || {})
|
|
51
|
+
.map(([k, v]) => {
|
|
52
|
+
if (k === 'children' || k === 'dangerouslySetInnerHTML') return '';
|
|
53
|
+
if (v === true) return k;
|
|
54
|
+
if (v === false || v === null || v === undefined) return '';
|
|
55
|
+
// Convert JSX attributes to HTML attributes
|
|
56
|
+
const attrName = k === 'className' ? 'class' : k === 'htmlFor' ? 'for' : k;
|
|
57
|
+
// Escape attribute values
|
|
58
|
+
const escaped = String(v).replace(/&/g, '&').replace(/"/g, '"');
|
|
59
|
+
return `${attrName}="${escaped}"`;
|
|
60
|
+
})
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(' ');
|
|
63
|
+
|
|
64
|
+
const openTag = attrs ? `<${type} ${attrs}>` : `<${type}>`;
|
|
65
|
+
|
|
66
|
+
// Handle dangerous HTML
|
|
67
|
+
if (props.dangerouslySetInnerHTML && props.dangerouslySetInnerHTML.__html) {
|
|
68
|
+
return `${openTag}${props.dangerouslySetInnerHTML.__html}</${type}>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Self-closing tags
|
|
72
|
+
if (['img', 'br', 'hr', 'input', 'meta', 'link'].includes(type)) {
|
|
73
|
+
return `<${type}${attrs ? ' ' + attrs : ''} />`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const childrenHtml = await renderToString(children);
|
|
77
|
+
return `${openTag}${childrenHtml}</${type}>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// For Hono JSX nodes - convert to plain HTML manually to avoid the str.search bug
|
|
81
|
+
if (vnode && typeof vnode === 'object' && (vnode.tag !== undefined || vnode.toStringToBuffer)) {
|
|
82
|
+
// This is a Hono JSX node - manually convert to HTML instead of using toString()
|
|
83
|
+
// to avoid the bug where non-string values cause errors in escapeToBuffer
|
|
84
|
+
|
|
85
|
+
const tag = vnode.tag;
|
|
86
|
+
const props = vnode.props || {};
|
|
87
|
+
const children = vnode.children || [];
|
|
88
|
+
|
|
89
|
+
// Handle fragments
|
|
90
|
+
if (!tag || tag === '') {
|
|
91
|
+
return await renderToString(children);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle function components
|
|
95
|
+
if (typeof tag === 'function') {
|
|
96
|
+
const result = tag(props);
|
|
97
|
+
return await renderToString(result);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Build attributes
|
|
101
|
+
const attrs = Object.entries(props)
|
|
102
|
+
.map(([k, v]) => {
|
|
103
|
+
if (k === 'children' || k === 'dangerouslySetInnerHTML') return '';
|
|
104
|
+
if (v === true) return k;
|
|
105
|
+
if (v === false || v === null || v === undefined) return '';
|
|
106
|
+
// Convert JSX attributes to HTML attributes
|
|
107
|
+
const attrName = k === 'className' ? 'class' : k === 'htmlFor' ? 'for' : k;
|
|
108
|
+
// Escape attribute values
|
|
109
|
+
const escaped = String(v).replace(/&/g, '&').replace(/"/g, '"');
|
|
110
|
+
return `${attrName}="${escaped}"`;
|
|
111
|
+
})
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join(' ');
|
|
114
|
+
|
|
115
|
+
// Handle dangerous HTML
|
|
116
|
+
if (props.dangerouslySetInnerHTML && props.dangerouslySetInnerHTML.__html) {
|
|
117
|
+
const openTag = attrs ? `<${tag} ${attrs}>` : `<${tag}>`;
|
|
118
|
+
return `${openTag}${props.dangerouslySetInnerHTML.__html}</${tag}>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Self-closing tags
|
|
122
|
+
if (['img', 'br', 'hr', 'input', 'meta', 'link'].includes(tag)) {
|
|
123
|
+
return `<${tag}${attrs ? ' ' + attrs : ''} />`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const openTag = attrs ? `<${tag} ${attrs}>` : `<${tag}>`;
|
|
127
|
+
const childrenHtml = await renderToString(children);
|
|
128
|
+
return `${openTag}${childrenHtml}</${tag}>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fallback for unexpected types
|
|
132
|
+
console.warn('[renderToString] Unexpected type:', typeof vnode, vnode);
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* router component
|
|
138
|
+
*/
|
|
139
|
+
export function Router({ children, i18nOpt }) {
|
|
140
|
+
if (i18nOpt) globalI18nConfig = i18nOpt;
|
|
141
|
+
|
|
142
|
+
// extract routes from children
|
|
143
|
+
if (Array.isArray(children)) {
|
|
144
|
+
children.forEach((child) => {
|
|
145
|
+
if (child && child.type === Route && child.props) routeRegistry.set(child.props.path, child.props.component);
|
|
146
|
+
});
|
|
147
|
+
} else if (children && children.type === Route && children.props) {
|
|
148
|
+
routeRegistry.set(children.props.path, children.props.component);
|
|
149
|
+
}
|
|
150
|
+
return children;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* route component
|
|
155
|
+
*/
|
|
156
|
+
export function Route({ path, component }) {
|
|
157
|
+
if (component) routeRegistry.set(path, component);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* FoxgirlCreampie component
|
|
163
|
+
*/
|
|
164
|
+
export function FoxgirlCreampie({ children }) {
|
|
165
|
+
return children;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* head component - collect metadata for page
|
|
170
|
+
*/
|
|
171
|
+
const headContext = {
|
|
172
|
+
title: 'App',
|
|
173
|
+
meta: [],
|
|
174
|
+
links: [],
|
|
175
|
+
scripts: [],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export function Head({ children }) {
|
|
179
|
+
// extract head elements during ssr
|
|
180
|
+
if (Array.isArray(children)) children.forEach((child) => processHeadChild(child));
|
|
181
|
+
else if (children) processHeadChild(children);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function Title({ children }) {
|
|
186
|
+
headContext.title = children;
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function Meta(props) {
|
|
191
|
+
headContext.meta.push(props);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function TwinkTag(props) {
|
|
196
|
+
headContext.links.push(props);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function Script(props) {
|
|
201
|
+
headContext.scripts.push(props);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function processHeadChild(child) {
|
|
206
|
+
if (!child) return;
|
|
207
|
+
if (child.type === 'title') headContext.title = child.children?.[0] || 'App';
|
|
208
|
+
else if (child.type === 'meta') headContext.meta.push(child.props);
|
|
209
|
+
else if (child.type === 'link') headContext.links.push(child.props);
|
|
210
|
+
else if (child.type === 'script') headContext.scripts.push(child.props);
|
|
211
|
+
else if (child.type === Title) headContext.title = child.children?.[0] || 'App';
|
|
212
|
+
else if (child.type === Meta) headContext.meta.push(child.props);
|
|
213
|
+
else if (child.type === TwinkTag) headContext.links.push(child.props);
|
|
214
|
+
else if (child.type === Script) headContext.scripts.push(child.props);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resetHeadContext() {
|
|
218
|
+
headContext.title = 'App';
|
|
219
|
+
headContext.meta = [];
|
|
220
|
+
headContext.links = [];
|
|
221
|
+
headContext.scripts = [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* html document template
|
|
226
|
+
* user provides head and body, cumstack handles structure and script injection
|
|
227
|
+
*/
|
|
228
|
+
let customHead = null;
|
|
229
|
+
let customBody = null;
|
|
230
|
+
|
|
231
|
+
export function setDocumentHead(headFn) {
|
|
232
|
+
customHead = headFn;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function setDocumentBody(bodyFn) {
|
|
236
|
+
customBody = bodyFn;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function Document({ content, language }) {
|
|
240
|
+
// render custom head or use default
|
|
241
|
+
let headHtml;
|
|
242
|
+
if (customHead) {
|
|
243
|
+
const headElements = customHead({ context: headContext });
|
|
244
|
+
headHtml = await renderToString(headElements);
|
|
245
|
+
} else {
|
|
246
|
+
// Render meta tags as strings
|
|
247
|
+
const metaTags = headContext.meta
|
|
248
|
+
.map((m) => {
|
|
249
|
+
const attrs = Object.entries(m)
|
|
250
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
251
|
+
.join(' ');
|
|
252
|
+
return `<meta ${attrs} />`;
|
|
253
|
+
})
|
|
254
|
+
.join('\n ');
|
|
255
|
+
|
|
256
|
+
const linkTags = headContext.links
|
|
257
|
+
.map((l) => {
|
|
258
|
+
const attrs = Object.entries(l)
|
|
259
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
260
|
+
.join(' ');
|
|
261
|
+
return `<link ${attrs} />`;
|
|
262
|
+
})
|
|
263
|
+
.join('\n ');
|
|
264
|
+
|
|
265
|
+
headHtml = `<meta charset="utf-8" />
|
|
266
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
267
|
+
${metaTags}
|
|
268
|
+
<title>${headContext.title}</title>
|
|
269
|
+
<link rel="stylesheet" href="/main.css" />
|
|
270
|
+
${linkTags}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// render custom body or use default
|
|
274
|
+
let bodyHtml;
|
|
275
|
+
if (customBody) {
|
|
276
|
+
const bodyContent = customBody({ content, language });
|
|
277
|
+
bodyHtml = await renderToString(bodyContent);
|
|
278
|
+
} else {
|
|
279
|
+
bodyHtml = `<body>
|
|
280
|
+
<div id="app">${content}</div>
|
|
281
|
+
</body>`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const scriptTags = headContext.scripts
|
|
285
|
+
.map((s) => {
|
|
286
|
+
const attrs = Object.entries(s)
|
|
287
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
288
|
+
.join(' ');
|
|
289
|
+
return `<script ${attrs}></script>`;
|
|
290
|
+
})
|
|
291
|
+
.join('\n ');
|
|
292
|
+
|
|
293
|
+
// Build full HTML document as string
|
|
294
|
+
return `<!DOCTYPE html>
|
|
295
|
+
<html lang="${language}">
|
|
296
|
+
<head>
|
|
297
|
+
${headHtml}
|
|
298
|
+
</head>
|
|
299
|
+
${bodyHtml}
|
|
300
|
+
<script>
|
|
301
|
+
window.__ENV__ = ${JSON.stringify(globalThis.__ENV__ || {})};
|
|
302
|
+
window.__ENVIRONMENT__ = ${JSON.stringify(globalThis.__ENVIRONMENT__ || 'production')};
|
|
303
|
+
window.__HMR_PORT__ = ${globalThis.__HMR_PORT__ || 8790};
|
|
304
|
+
window.__BUILD_TIMESTAMP__ = ${globalThis.__BUILD_TIMESTAMP__ || Date.now()};
|
|
305
|
+
</script>
|
|
306
|
+
<script type="module" src="/main.client.js"></script>
|
|
307
|
+
${scriptTags}
|
|
308
|
+
</html>`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* detect language from request
|
|
313
|
+
*/
|
|
314
|
+
function detectLanguageFromRequest(request, config) {
|
|
315
|
+
const url = new URL(request.url);
|
|
316
|
+
// check url path first if explicit routing
|
|
317
|
+
if (config.explicitRouting) {
|
|
318
|
+
const { language } = extractLanguageFromRoute(url.pathname);
|
|
319
|
+
if (language && config.supportedLanguages?.includes(language)) return language;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// check accept-language header if auto-detection is enabled
|
|
323
|
+
if (config.defaultLanguage === 'auto') {
|
|
324
|
+
const acceptLang = request.headers.get('accept-language');
|
|
325
|
+
if (acceptLang) {
|
|
326
|
+
// parse all language preferences
|
|
327
|
+
const languages = acceptLang.split(',').map((lang) => lang.split(';')[0].trim().split('-')[0]);
|
|
328
|
+
// find first supported language
|
|
329
|
+
for (const lang of languages) {
|
|
330
|
+
if (config.supportedLanguages?.includes(lang)) {
|
|
331
|
+
return lang;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// use fallback
|
|
338
|
+
return config.fallbackLanguage || 'en';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* create server handler with hono
|
|
343
|
+
*/
|
|
344
|
+
export async function foxgirl(app, options = {}) {
|
|
345
|
+
honoApp = new Hono();
|
|
346
|
+
// call app to execute router and extract routes
|
|
347
|
+
const appStructure = app();
|
|
348
|
+
// process the structure to extract routes
|
|
349
|
+
if (appStructure && appStructure.type === FoxgirlCreampie) {
|
|
350
|
+
const routerNode = Array.isArray(appStructure.children)
|
|
351
|
+
? appStructure.children.find((child) => child && child.type === Router)
|
|
352
|
+
: appStructure.children && appStructure.children.type === Router
|
|
353
|
+
? appStructure.children
|
|
354
|
+
: null;
|
|
355
|
+
if (routerNode && routerNode.props) Router(routerNode.props);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// note: register translations in your entry.server.jsx using registertranslations()
|
|
359
|
+
// before calling foxgirl
|
|
360
|
+
|
|
361
|
+
// environment middleware
|
|
362
|
+
honoApp.use('*', async (c, next) => {
|
|
363
|
+
globalThis.__DEPLOYMENT__ = c.env?.CF_VERSION_METADATA ?? null;
|
|
364
|
+
globalThis.__ENV__ = c.env ?? {};
|
|
365
|
+
globalThis.__ENVIRONMENT__ = c.env?.ENVIRONMENT ?? 'development';
|
|
366
|
+
globalThis.__HMR_PORT__ = c.env?.DEV_RELOAD_PORT || 8790;
|
|
367
|
+
await next();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// custom middlewares
|
|
371
|
+
if (options.middlewares) options.middlewares(honoApp);
|
|
372
|
+
|
|
373
|
+
// language detection
|
|
374
|
+
honoApp.use('*', async (c, next) => {
|
|
375
|
+
const language = detectLanguageFromRequest(
|
|
376
|
+
c.req.raw,
|
|
377
|
+
globalI18nConfig || {
|
|
378
|
+
supportedLanguages: ['en'],
|
|
379
|
+
defaultLanguage: 'en',
|
|
380
|
+
fallbackLanguage: 'en',
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
setLanguage(language);
|
|
384
|
+
c.set('language', language);
|
|
385
|
+
await next();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// register jsx routes
|
|
389
|
+
for (const [path, component] of routeRegistry.entries()) {
|
|
390
|
+
const patterns = [];
|
|
391
|
+
if (globalI18nConfig?.explicitRouting) {
|
|
392
|
+
globalI18nConfig.supportedLanguages.forEach((lang) => {
|
|
393
|
+
patterns.push(`/${lang}${path === '/' ? '' : path}`);
|
|
394
|
+
if (path === '/') patterns.push(`/${lang}`);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
patterns.push(path);
|
|
398
|
+
patterns.forEach((pattern) => {
|
|
399
|
+
honoApp.get(pattern, async (c) => {
|
|
400
|
+
try {
|
|
401
|
+
resetHeadContext();
|
|
402
|
+
const language = c.get('language');
|
|
403
|
+
const pageContent = component();
|
|
404
|
+
// Convert pageContent to string first before passing to Document
|
|
405
|
+
const contentHtml = await renderToString(pageContent);
|
|
406
|
+
const html = await Document({ content: contentHtml, language });
|
|
407
|
+
// Return HTML response
|
|
408
|
+
return new Response(html, {
|
|
409
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
410
|
+
});
|
|
411
|
+
} catch (error) {
|
|
412
|
+
console.error('Route error:', error);
|
|
413
|
+
return c.text('Internal Server Error', 500);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
// custom api routes
|
|
419
|
+
if (options.routes) options.routes(honoApp);
|
|
420
|
+
// 404 handler
|
|
421
|
+
honoApp.notFound(async (c) => {
|
|
422
|
+
const html = await Document({
|
|
423
|
+
content: '<div><h1>404 - Not Found</h1></div>',
|
|
424
|
+
language: c.get('language') || 'en',
|
|
425
|
+
});
|
|
426
|
+
// Return with 404 status
|
|
427
|
+
return new Response(html, {
|
|
428
|
+
status: 404,
|
|
429
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
// error handler
|
|
433
|
+
honoApp.onError((err, c) => {
|
|
434
|
+
console.error('Server error:', err);
|
|
435
|
+
return c.text('Internal Server Error', 500);
|
|
436
|
+
});
|
|
437
|
+
return honoApp;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function getRouteRegistry() {
|
|
441
|
+
return routeRegistry;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function getI18nConfig() {
|
|
445
|
+
return globalI18nConfig;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* get environment variable (server-side only)
|
|
450
|
+
* @param {string} key - environment variable name
|
|
451
|
+
* @param {string} [fallback] - fallback value if not found
|
|
452
|
+
* @returns {string|undefined} environment variable value
|
|
453
|
+
*/
|
|
454
|
+
export function env(key, fallback) {
|
|
455
|
+
const envVars = globalThis.__ENV__ || {};
|
|
456
|
+
return envVars[key] ?? fallback;
|
|
457
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack JSX Utilities
|
|
3
|
+
* helper functions for jsx rendering and dom manipulation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* create an element
|
|
8
|
+
* @param {string|Function} type - Element type or component
|
|
9
|
+
* @param {Object} props - Element props
|
|
10
|
+
* @param {...any} children - Child elements
|
|
11
|
+
*/
|
|
12
|
+
export function h(type, props, ...children) {
|
|
13
|
+
if (typeof type === 'function') return type({ ...props, children });
|
|
14
|
+
return {
|
|
15
|
+
type,
|
|
16
|
+
props: props || {},
|
|
17
|
+
children: children.flat(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* fragment component
|
|
23
|
+
*/
|
|
24
|
+
export function Fragment({ children }) {
|
|
25
|
+
return children;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* render jsx to dom (client-side)
|
|
30
|
+
* @param {any} vnode - Virtual node
|
|
31
|
+
* @param {HTMLElement} container - Container element
|
|
32
|
+
*/
|
|
33
|
+
export function render(vnode, container) {
|
|
34
|
+
// clear container
|
|
35
|
+
container.innerHTML = '';
|
|
36
|
+
const element = createDOMElement(vnode);
|
|
37
|
+
if (element) {
|
|
38
|
+
container.appendChild(element);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* create dom element from virtual node
|
|
44
|
+
* @param {any} vnode - Virtual node
|
|
45
|
+
* @returns {Node|null}
|
|
46
|
+
*/
|
|
47
|
+
function createDOMElement(vnode) {
|
|
48
|
+
// handle null/undefined
|
|
49
|
+
if (vnode == null || vnode === false) return null;
|
|
50
|
+
// handle text nodes
|
|
51
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));
|
|
52
|
+
// handle arrays
|
|
53
|
+
if (Array.isArray(vnode)) {
|
|
54
|
+
const fragment = document.createDocumentFragment();
|
|
55
|
+
vnode.forEach((child) => {
|
|
56
|
+
const element = createDOMElement(child);
|
|
57
|
+
if (element) fragment.appendChild(element);
|
|
58
|
+
});
|
|
59
|
+
return fragment;
|
|
60
|
+
}
|
|
61
|
+
// handle components (already rendered)
|
|
62
|
+
if (!vnode.type) return createDOMElement(vnode);
|
|
63
|
+
// create element
|
|
64
|
+
const element = document.createElement(vnode.type);
|
|
65
|
+
// set props
|
|
66
|
+
Object.entries(vnode.props || {}).forEach(([key, value]) => {
|
|
67
|
+
if (key === 'className') element.className = value;
|
|
68
|
+
else if (key === 'style' && typeof value === 'object') Object.assign(element.style, value);
|
|
69
|
+
else if (key.startsWith('on') && typeof value === 'function') {
|
|
70
|
+
const eventName = key.slice(2).toLowerCase();
|
|
71
|
+
element.addEventListener(eventName, value);
|
|
72
|
+
} else if (value != null && value !== false) element.setAttribute(key, value);
|
|
73
|
+
});
|
|
74
|
+
// append children
|
|
75
|
+
(vnode.children || []).forEach((child) => {
|
|
76
|
+
const childElement = createDOMElement(child);
|
|
77
|
+
if (childElement) element.appendChild(childElement);
|
|
78
|
+
});
|
|
79
|
+
return element;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* render to string (server-side)
|
|
84
|
+
* @param {any} vnode - Virtual node
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function renderToString(vnode) {
|
|
88
|
+
// handle null/undefined
|
|
89
|
+
if (vnode == null || vnode === false) return '';
|
|
90
|
+
// handle text nodes
|
|
91
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') return escapeHtml(String(vnode));
|
|
92
|
+
// handle arrays
|
|
93
|
+
if (Array.isArray(vnode)) return vnode.map(renderToString).join('');
|
|
94
|
+
// handle components (already rendered) - but prevent infinite recursion
|
|
95
|
+
if (!vnode.type) {
|
|
96
|
+
// if it's an object without type, try to extract meaningful content
|
|
97
|
+
if (typeof vnode === 'object') {
|
|
98
|
+
// check for children property
|
|
99
|
+
if (vnode.children !== undefined) return renderToString(vnode.children);
|
|
100
|
+
// check for props.children
|
|
101
|
+
if (vnode.props?.children !== undefined) return renderToString(vnode.props.children);
|
|
102
|
+
// unknown structure - return empty
|
|
103
|
+
console.warn('renderToString: Unknown vnode structure', vnode);
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
// self-closing tags
|
|
109
|
+
const selfClosing = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
|
110
|
+
// build attributes
|
|
111
|
+
const attrs = Object.entries(vnode.props || {})
|
|
112
|
+
.filter(([key, value]) => !key.startsWith('on') && value != null && value !== false)
|
|
113
|
+
.map(([key, value]) => {
|
|
114
|
+
const attrName = key === 'className' ? 'class' : key;
|
|
115
|
+
if (typeof value === 'object' && key === 'style') {
|
|
116
|
+
const styleStr = Object.entries(value)
|
|
117
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
118
|
+
.join('; ');
|
|
119
|
+
return `${attrName}="${escapeHtml(styleStr)}"`;
|
|
120
|
+
}
|
|
121
|
+
return `${attrName}="${escapeHtml(String(value))}"`;
|
|
122
|
+
})
|
|
123
|
+
.join(' ');
|
|
124
|
+
const openTag = attrs ? `<${vnode.type} ${attrs}>` : `<${vnode.type}>`;
|
|
125
|
+
if (selfClosing.includes(vnode.type)) return openTag.replace('>', ' />');
|
|
126
|
+
const children = (vnode.children || []).map(renderToString).join('');
|
|
127
|
+
return `${openTag}${children}</${vnode.type}>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* escape html
|
|
132
|
+
* @param {string} str - String to escape
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function escapeHtml(str) {
|
|
136
|
+
const map = {
|
|
137
|
+
'&': '&',
|
|
138
|
+
'<': '<',
|
|
139
|
+
'>': '>',
|
|
140
|
+
'"': '"',
|
|
141
|
+
"'": ''',
|
|
142
|
+
};
|
|
143
|
+
return str.replace(/[&<>"']/g, (char) => map[char]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* conditional rendering helper
|
|
148
|
+
* @param {Object} props - Component props
|
|
149
|
+
* @param {boolean} props.when - Condition to show children
|
|
150
|
+
* @param {*} props.children - Children to show when condition is true
|
|
151
|
+
* @param {*} [props.fallback] - Fallback content when condition is false
|
|
152
|
+
* @returns {*} Rendered content
|
|
153
|
+
*/
|
|
154
|
+
export function Show({ when, children, fallback = null }) {
|
|
155
|
+
return when ? children : fallback;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* list rendering helper
|
|
160
|
+
* @param {Object} props - Component props
|
|
161
|
+
* @param {Array} props.each - Array to iterate
|
|
162
|
+
* @param {Function} props.children - Render function (item, index) => element
|
|
163
|
+
* @returns {Array|null} Array of rendered elements
|
|
164
|
+
*/
|
|
165
|
+
export function For({ each, children }) {
|
|
166
|
+
if (!Array.isArray(each)) return null;
|
|
167
|
+
return each.map((item, index) => children(item, index));
|
|
168
|
+
}
|