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.
@@ -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, '&amp;').replace(/"/g, '&quot;');
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
+ '&': '&amp;',
138
+ '<': '&lt;',
139
+ '>': '&gt;',
140
+ '"': '&quot;',
141
+ "'": '&#39;',
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
+ }