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,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack Client Module
|
|
3
|
+
* client-side rendering and hydration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { render } from './server/jsx.js';
|
|
7
|
+
import { createRouter } from './shared/router.js';
|
|
8
|
+
import { initI18n, setLanguage, extractLanguageFromRoute } from './shared/i18n.js';
|
|
9
|
+
import { onClimax } from './shared/reactivity.js';
|
|
10
|
+
|
|
11
|
+
let clientRouter = null;
|
|
12
|
+
let i18nConfig = null;
|
|
13
|
+
const registeredRoutes = new WeakMap();
|
|
14
|
+
let isRouterInitialized = false;
|
|
15
|
+
|
|
16
|
+
// hydration configuration
|
|
17
|
+
const hydrationConfig = {
|
|
18
|
+
isProduction: typeof window !== 'undefined' && window.location.hostname !== 'localhost',
|
|
19
|
+
logMismatches: true,
|
|
20
|
+
partialHydration: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// event delegation cache
|
|
24
|
+
const delegatedEvents = new Map();
|
|
25
|
+
const eventDelegationRoot = typeof document !== 'undefined' ? document : null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* get initial data from server
|
|
29
|
+
* @returns {Object|null} Parsed initial data or null
|
|
30
|
+
*/
|
|
31
|
+
function getInitialData() {
|
|
32
|
+
if (typeof window === 'undefined') return null;
|
|
33
|
+
const script = document.getElementById('cumstack-data');
|
|
34
|
+
if (script && script.textContent) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(script.textContent);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error('Failed to parse initial data:', e);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* initialize i18n on client
|
|
46
|
+
* @param {Object} config - i18n configuration
|
|
47
|
+
*/
|
|
48
|
+
function initializeI18n(config) {
|
|
49
|
+
i18nConfig = config;
|
|
50
|
+
const initialData = getInitialData();
|
|
51
|
+
const initialLang = initialData?.language || config.fallbackLng || 'en';
|
|
52
|
+
initI18n({
|
|
53
|
+
defaultLanguage: initialLang,
|
|
54
|
+
detectBrowser: config.defaultLng === 'auto',
|
|
55
|
+
});
|
|
56
|
+
setLanguage(initialLang);
|
|
57
|
+
if (typeof window !== 'undefined') window.__HONMOON_I18N_CONFIG__ = config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* router component (client)
|
|
62
|
+
* @param {Object} props - Component props
|
|
63
|
+
* @param {Object} [props.i18nOpt] - i18n options
|
|
64
|
+
* @param {*} props.children - Child elements
|
|
65
|
+
* @returns {*} Children
|
|
66
|
+
*/
|
|
67
|
+
export function Router({ i18nOpt, children }) {
|
|
68
|
+
if (i18nOpt && !i18nConfig) initializeI18n(i18nOpt);
|
|
69
|
+
return children;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* route component (client)
|
|
74
|
+
* @param {Object} props - Component props
|
|
75
|
+
* @param {string} props.path - Route path
|
|
76
|
+
* @param {Function} [props.component] - Component function
|
|
77
|
+
* @param {*} [props.element] - Element to render
|
|
78
|
+
* @param {*} [props.children] - Child elements
|
|
79
|
+
* @returns {null}
|
|
80
|
+
*/
|
|
81
|
+
export function Route({ path, component, element, children }) {
|
|
82
|
+
if (!clientRouter) clientRouter = createRouter();
|
|
83
|
+
const handler = () => {
|
|
84
|
+
if (component) return component();
|
|
85
|
+
if (element) return element;
|
|
86
|
+
return children;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// prevent duplicate registration
|
|
90
|
+
const routeKey = { path };
|
|
91
|
+
if (!registeredRoutes.has(routeKey)) {
|
|
92
|
+
clientRouter.register(path, handler);
|
|
93
|
+
registeredRoutes.set(routeKey, true);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* CowgirlCreampie component
|
|
100
|
+
* @param {Object} props - Component props
|
|
101
|
+
* @param {*} props.children - Child elements
|
|
102
|
+
* @returns {*} Children
|
|
103
|
+
*/
|
|
104
|
+
export function CowgirlCreampie({ children }) {
|
|
105
|
+
return children;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* configure hydration behavior
|
|
110
|
+
* @param {Object} config - Configuration options
|
|
111
|
+
* @param {boolean} [config.isProduction] - Whether in production mode
|
|
112
|
+
* @param {boolean} [config.logMismatches] - Whether to log hydration mismatches
|
|
113
|
+
* @param {boolean} [config.partialHydration] - Enable partial hydration
|
|
114
|
+
*/
|
|
115
|
+
export function configureHydration(config) {
|
|
116
|
+
Object.assign(hydrationConfig, config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* log hydration warning (respects production mode)
|
|
121
|
+
* @param {string} message - Warning message
|
|
122
|
+
* @param {Object} [data] - Additional data
|
|
123
|
+
*/
|
|
124
|
+
function logHydrationWarning(message, data) {
|
|
125
|
+
if (!hydrationConfig.isProduction && hydrationConfig.logMismatches) console.warn(`[Hydration] ${message}`, data || '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* setup event delegation for better performance
|
|
130
|
+
* @param {string} eventName - Event name (e.g., 'click')
|
|
131
|
+
* @param {HTMLElement} element - Element to attach listener to
|
|
132
|
+
* @param {Function} handler - Event handler
|
|
133
|
+
* @returns {Function} Cleanup function
|
|
134
|
+
*/
|
|
135
|
+
function delegateEvent(eventName, element, handler) {
|
|
136
|
+
if (!eventDelegationRoot) return () => {};
|
|
137
|
+
const eventKey = `__cumstack_${eventName}_handler`;
|
|
138
|
+
if (!element[eventKey]) element[eventKey] = [];
|
|
139
|
+
element[eventKey].push(handler);
|
|
140
|
+
// setup delegated listener if not already present
|
|
141
|
+
if (!delegatedEvents.has(eventName)) {
|
|
142
|
+
const delegatedHandler = (e) => {
|
|
143
|
+
let target = e.target;
|
|
144
|
+
while (target && target !== eventDelegationRoot) {
|
|
145
|
+
const handlers = target[eventKey];
|
|
146
|
+
if (handlers) {
|
|
147
|
+
handlers.forEach((h) => h.call(target, e));
|
|
148
|
+
if (e.cancelBubble) return;
|
|
149
|
+
}
|
|
150
|
+
target = target.parentElement;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
eventDelegationRoot.addEventListener(eventName, delegatedHandler, true);
|
|
154
|
+
delegatedEvents.set(eventName, delegatedHandler);
|
|
155
|
+
}
|
|
156
|
+
// return cleanup function
|
|
157
|
+
return () => {
|
|
158
|
+
const handlers = element[eventKey];
|
|
159
|
+
if (handlers) {
|
|
160
|
+
const index = handlers.indexOf(handler);
|
|
161
|
+
if (index > -1) handlers.splice(index, 1);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* match vnode with DOM node using keys for better reconciliation
|
|
168
|
+
* @param {any} vnode - Virtual node
|
|
169
|
+
* @param {Node} domNode - DOM node
|
|
170
|
+
* @returns {boolean} Whether nodes match
|
|
171
|
+
*/
|
|
172
|
+
function nodesMatch(vnode, domNode) {
|
|
173
|
+
// text nodes
|
|
174
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') return domNode.nodeType === Node.TEXT_NODE;
|
|
175
|
+
// element nodes
|
|
176
|
+
if (vnode.type && domNode.nodeType === Node.ELEMENT_NODE) {
|
|
177
|
+
const tagMatch = domNode.tagName.toLowerCase() === vnode.type.toLowerCase();
|
|
178
|
+
// check key if present for better matching
|
|
179
|
+
if (vnode.props?.key && domNode.dataset?.key) return tagMatch && domNode.dataset.key === String(vnode.props.key);
|
|
180
|
+
return tagMatch;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* hydrate existing dom with virtual node
|
|
187
|
+
* @param {any} vnode - Virtual node
|
|
188
|
+
* @param {Node} domNode - Existing DOM node
|
|
189
|
+
* @returns {Node} Hydrated DOM node
|
|
190
|
+
*/
|
|
191
|
+
function hydrateDOMElement(vnode, domNode) {
|
|
192
|
+
// handle null/undefined
|
|
193
|
+
if (vnode == null || vnode === false) return domNode;
|
|
194
|
+
// handle text nodes
|
|
195
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') {
|
|
196
|
+
if (domNode.nodeType === Node.TEXT_NODE) {
|
|
197
|
+
const vnodeText = String(vnode);
|
|
198
|
+
if (domNode.textContent !== vnodeText) {
|
|
199
|
+
logHydrationWarning('Text node mismatch', { expected: vnodeText, got: domNode.textContent });
|
|
200
|
+
domNode.textContent = vnodeText;
|
|
201
|
+
}
|
|
202
|
+
return domNode;
|
|
203
|
+
}
|
|
204
|
+
// mismatch: replace with text node
|
|
205
|
+
logHydrationWarning('Expected text node, got element');
|
|
206
|
+
const textNode = document.createTextNode(String(vnode));
|
|
207
|
+
domNode.parentNode?.replaceChild(textNode, domNode);
|
|
208
|
+
return textNode;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// handle arrays - arrays represent siblings, not a container
|
|
212
|
+
if (Array.isArray(vnode)) {
|
|
213
|
+
const fragment = document.createDocumentFragment();
|
|
214
|
+
let currentNode = domNode;
|
|
215
|
+
for (let i = 0; i < vnode.length; i++) {
|
|
216
|
+
const child = vnode[i];
|
|
217
|
+
if (child == null || child === false) continue;
|
|
218
|
+
if (currentNode) {
|
|
219
|
+
const nextNode = currentNode.nextSibling;
|
|
220
|
+
const hydratedNode = hydrateDOMElement(child, currentNode);
|
|
221
|
+
fragment.appendChild(hydratedNode);
|
|
222
|
+
currentNode = nextNode;
|
|
223
|
+
} else {
|
|
224
|
+
// no more DOM nodes, create new ones
|
|
225
|
+
const newNode = createDOMElementForHydration(child);
|
|
226
|
+
if (newNode) fragment.appendChild(newNode);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return fragment;
|
|
230
|
+
}
|
|
231
|
+
// handle fragments or already rendered components
|
|
232
|
+
if (!vnode.type) {
|
|
233
|
+
// if it's an object without a type, it's likely already rendered - treat as children
|
|
234
|
+
if (typeof vnode === 'object') {
|
|
235
|
+
// could be fragment result or component output
|
|
236
|
+
if (vnode.children) return hydrateDOMElement(vnode.children, domNode);
|
|
237
|
+
if (vnode.props?.children) return hydrateDOMElement(vnode.props.children, domNode);
|
|
238
|
+
// fallback
|
|
239
|
+
logHydrationWarning('Unknown vnode structure', vnode);
|
|
240
|
+
return domNode;
|
|
241
|
+
}
|
|
242
|
+
return hydrateDOMElement(vnode, domNode);
|
|
243
|
+
}
|
|
244
|
+
// handle element nodes
|
|
245
|
+
if (domNode.nodeType !== Node.ELEMENT_NODE) {
|
|
246
|
+
logHydrationWarning('Expected element node', { vnode, domNode });
|
|
247
|
+
const newElement = createDOMElementForHydration(vnode);
|
|
248
|
+
domNode.parentNode?.replaceChild(newElement, domNode);
|
|
249
|
+
return newElement;
|
|
250
|
+
}
|
|
251
|
+
const element = domNode;
|
|
252
|
+
// check tag name matches
|
|
253
|
+
if (element.tagName.toLowerCase() !== vnode.type.toLowerCase()) {
|
|
254
|
+
logHydrationWarning('Tag name mismatch', {
|
|
255
|
+
expected: vnode.type,
|
|
256
|
+
got: element.tagName.toLowerCase(),
|
|
257
|
+
});
|
|
258
|
+
const newElement = createDOMElementForHydration(vnode);
|
|
259
|
+
element.parentNode?.replaceChild(newElement, element);
|
|
260
|
+
return newElement;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// check partial hydration marker
|
|
264
|
+
if (hydrationConfig.partialHydration && element.hasAttribute('data-cumstack-static')) return element;
|
|
265
|
+
// hydrate props
|
|
266
|
+
const booleanAttrs = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple', 'autofocus']);
|
|
267
|
+
Object.entries(vnode.props || {}).forEach(([key, value]) => {
|
|
268
|
+
if (key === 'key') {
|
|
269
|
+
// store key as data attribute for reconciliation
|
|
270
|
+
if (!element.dataset.key) element.dataset.key = String(value);
|
|
271
|
+
} else if (key === 'className') {
|
|
272
|
+
if (element.className !== value) {
|
|
273
|
+
logHydrationWarning('className mismatch', { expected: value, got: element.className });
|
|
274
|
+
element.className = value;
|
|
275
|
+
}
|
|
276
|
+
} else if (key === 'style' && typeof value === 'object') {
|
|
277
|
+
Object.assign(element.style, value);
|
|
278
|
+
} else if (key.startsWith('on') && typeof value === 'function') {
|
|
279
|
+
// use event delegation for better performance
|
|
280
|
+
const eventName = key.slice(2).toLowerCase();
|
|
281
|
+
delegateEvent(eventName, element, value);
|
|
282
|
+
} else if (key === 'innerHTML') {
|
|
283
|
+
// skip innerHTML during hydration - it's already set by SSR
|
|
284
|
+
} else if (key === 'value' && (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA')) {
|
|
285
|
+
// special handling for form values
|
|
286
|
+
if (element.value !== value) element.value = value;
|
|
287
|
+
} else if (booleanAttrs.has(key)) {
|
|
288
|
+
// boolean attributes - check property not attribute
|
|
289
|
+
if (element[key] !== value) element[key] = value;
|
|
290
|
+
} else if (key === 'ref' && typeof value === 'function') {
|
|
291
|
+
// handle refs
|
|
292
|
+
value(element);
|
|
293
|
+
} else if (value != null && value !== false) {
|
|
294
|
+
const currentValue = element.getAttribute(key);
|
|
295
|
+
if (currentValue !== String(value)) {
|
|
296
|
+
logHydrationWarning('Attribute mismatch', { key, expected: value, got: currentValue });
|
|
297
|
+
element.setAttribute(key, value);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// hydrate children
|
|
302
|
+
const vnodeChildren = vnode.children || [];
|
|
303
|
+
let domChild = element.firstChild;
|
|
304
|
+
let vnodeIndex = 0;
|
|
305
|
+
// helper to skip whitespace-only text nodes
|
|
306
|
+
const skipWhitespace = (node) => {
|
|
307
|
+
while (node && node.nodeType === Node.TEXT_NODE && !node.textContent.trim()) node = node.nextSibling;
|
|
308
|
+
return node;
|
|
309
|
+
};
|
|
310
|
+
domChild = skipWhitespace(domChild);
|
|
311
|
+
while (vnodeIndex < vnodeChildren.length || domChild) {
|
|
312
|
+
if (vnodeIndex >= vnodeChildren.length) {
|
|
313
|
+
// extra dom nodes - remove them (unless whitespace)
|
|
314
|
+
const nextSibling = domChild.nextSibling;
|
|
315
|
+
if (domChild.nodeType === Node.TEXT_NODE && !domChild.textContent.trim()) {
|
|
316
|
+
domChild = skipWhitespace(nextSibling);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
logHydrationWarning('Extra DOM nodes', domChild);
|
|
320
|
+
element.removeChild(domChild);
|
|
321
|
+
domChild = skipWhitespace(nextSibling);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!domChild) {
|
|
325
|
+
// missing dom nodes - create them
|
|
326
|
+
logHydrationWarning('Missing DOM nodes');
|
|
327
|
+
const newChild = createDOMElementForHydration(vnodeChildren[vnodeIndex]);
|
|
328
|
+
if (newChild) element.appendChild(newChild);
|
|
329
|
+
vnodeIndex++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const vChild = vnodeChildren[vnodeIndex];
|
|
333
|
+
// skip null/false vnodes
|
|
334
|
+
if (vChild == null || vChild === false) {
|
|
335
|
+
vnodeIndex++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// check if nodes match (using keys if available)
|
|
339
|
+
if (!nodesMatch(vChild, domChild)) {
|
|
340
|
+
// try to find matching node by key
|
|
341
|
+
if (vChild.props?.key) {
|
|
342
|
+
let foundNode = null;
|
|
343
|
+
let tempNode = domChild.nextSibling;
|
|
344
|
+
while (tempNode && !foundNode) {
|
|
345
|
+
if (nodesMatch(vChild, tempNode)) {
|
|
346
|
+
foundNode = tempNode;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
tempNode = tempNode.nextSibling;
|
|
350
|
+
}
|
|
351
|
+
if (foundNode) {
|
|
352
|
+
// move node to correct position
|
|
353
|
+
element.insertBefore(foundNode, domChild);
|
|
354
|
+
const nextSibling = foundNode.nextSibling;
|
|
355
|
+
hydrateDOMElement(vChild, foundNode);
|
|
356
|
+
domChild = skipWhitespace(nextSibling);
|
|
357
|
+
vnodeIndex++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// no match found - replace node
|
|
362
|
+
logHydrationWarning('Node mismatch, replacing');
|
|
363
|
+
const newChild = createDOMElementForHydration(vChild);
|
|
364
|
+
if (newChild) element.replaceChild(newChild, domChild);
|
|
365
|
+
domChild = skipWhitespace(domChild.nextSibling);
|
|
366
|
+
vnodeIndex++;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const nextSibling = domChild.nextSibling;
|
|
370
|
+
hydrateDOMElement(vChild, domChild);
|
|
371
|
+
domChild = skipWhitespace(nextSibling);
|
|
372
|
+
vnodeIndex++;
|
|
373
|
+
}
|
|
374
|
+
return element;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* create dom element from virtual node (used during hydration mismatches)
|
|
379
|
+
* @param {any} vnode - Virtual node
|
|
380
|
+
* @returns {Node|null}
|
|
381
|
+
*/
|
|
382
|
+
function createDOMElementForHydration(vnode) {
|
|
383
|
+
// handle null/undefined
|
|
384
|
+
if (vnode == null || vnode === false) return null;
|
|
385
|
+
// handle text nodes
|
|
386
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));
|
|
387
|
+
// handle arrays
|
|
388
|
+
if (Array.isArray(vnode)) {
|
|
389
|
+
const fragment = document.createDocumentFragment();
|
|
390
|
+
vnode.forEach((child) => {
|
|
391
|
+
const element = createDOMElementForHydration(child);
|
|
392
|
+
if (element) fragment.appendChild(element);
|
|
393
|
+
});
|
|
394
|
+
return fragment;
|
|
395
|
+
}
|
|
396
|
+
// handle components (already rendered)
|
|
397
|
+
if (!vnode.type) return createDOMElementForHydration(vnode);
|
|
398
|
+
// create element
|
|
399
|
+
const element = document.createElement(vnode.type);
|
|
400
|
+
// set props
|
|
401
|
+
Object.entries(vnode.props || {}).forEach(([key, value]) => {
|
|
402
|
+
if (key === 'key') element.dataset.key = String(value);
|
|
403
|
+
else if (key === 'className') element.className = value;
|
|
404
|
+
else if (key === 'style' && typeof value === 'object') Object.assign(element.style, value);
|
|
405
|
+
else if (key.startsWith('on') && typeof value === 'function') {
|
|
406
|
+
// use event delegation
|
|
407
|
+
const eventName = key.slice(2).toLowerCase();
|
|
408
|
+
delegateEvent(eventName, element, value);
|
|
409
|
+
} else if (key === 'innerHTML') element.innerHTML = value;
|
|
410
|
+
else if (key === 'ref' && typeof value === 'function') value(element);
|
|
411
|
+
else if (value != null && value !== false) element.setAttribute(key, value);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// append children
|
|
415
|
+
(vnode.children || []).forEach((child) => {
|
|
416
|
+
const childElement = createDOMElementForHydration(child);
|
|
417
|
+
if (childElement) element.appendChild(childElement);
|
|
418
|
+
});
|
|
419
|
+
return element;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* cowgirl - mount and hydrate app
|
|
424
|
+
* @param {Function} app - App component function
|
|
425
|
+
* @param {HTMLElement|string} container - Container element or selector
|
|
426
|
+
* @returns {Function} Cleanup function to dispose the app
|
|
427
|
+
*/
|
|
428
|
+
export function cowgirl(app, container) {
|
|
429
|
+
// validate container
|
|
430
|
+
const containerEl = typeof container === 'string' ? document.querySelector(container) : container;
|
|
431
|
+
if (!containerEl || !(containerEl instanceof HTMLElement)) throw new Error('cumstack: Container must be a valid HTMLElement or selector');
|
|
432
|
+
if (!clientRouter) clientRouter = createRouter();
|
|
433
|
+
// initialize router only once
|
|
434
|
+
if (!isRouterInitialized) {
|
|
435
|
+
clientRouter.init();
|
|
436
|
+
isRouterInitialized = true;
|
|
437
|
+
}
|
|
438
|
+
// check if we're hydrating ssr content
|
|
439
|
+
const isHydrating = containerEl.hasAttribute('data-cumstack-ssr') || containerEl.querySelector('[data-cumstack-ssr]') !== null;
|
|
440
|
+
// cleanup functions
|
|
441
|
+
const cleanupFns = [];
|
|
442
|
+
// track if first render (for hydration)
|
|
443
|
+
let isFirstRender = true;
|
|
444
|
+
// create effect with error boundary
|
|
445
|
+
const disposeEffect = onClimax(() => {
|
|
446
|
+
try {
|
|
447
|
+
const path = clientRouter.currentPath();
|
|
448
|
+
const match = clientRouter.matchRoute();
|
|
449
|
+
if (match) {
|
|
450
|
+
const content = match.handler({
|
|
451
|
+
path,
|
|
452
|
+
params: clientRouter.currentParams(),
|
|
453
|
+
});
|
|
454
|
+
if (isHydrating && isFirstRender && containerEl.firstChild) {
|
|
455
|
+
if (!hydrationConfig.isProduction) console.log('Hydrating existing content');
|
|
456
|
+
const appRoot = containerEl.querySelector('.app-root') || containerEl.firstChild;
|
|
457
|
+
hydrateDOMElement(content, appRoot);
|
|
458
|
+
isFirstRender = false;
|
|
459
|
+
} else render(content, containerEl);
|
|
460
|
+
}
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error('cumstack render error:', error);
|
|
463
|
+
containerEl.innerHTML = `<div style="color: red; padding: 20px;">Render Error: ${error.message}</div>`;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
cleanupFns.push(disposeEffect);
|
|
467
|
+
// handle navigation clicks
|
|
468
|
+
const clickHandler = (e) => {
|
|
469
|
+
const link = e.target.closest('a[href]');
|
|
470
|
+
if (!link) return;
|
|
471
|
+
const href = link.getAttribute('href');
|
|
472
|
+
if (!href) return;
|
|
473
|
+
const isSpaTwink = link.hasAttribute('data-spa-link');
|
|
474
|
+
const isNoSpa = link.hasAttribute('data-no-spa');
|
|
475
|
+
if (isNoSpa) return;
|
|
476
|
+
const isInternal = href.startsWith('/') && !href.startsWith('//');
|
|
477
|
+
if (isInternal || isSpaTwink) {
|
|
478
|
+
e.preventDefault();
|
|
479
|
+
if (i18nConfig?.explicitRouting) {
|
|
480
|
+
const { language } = extractLanguageFromRoute(href);
|
|
481
|
+
if (language) setLanguage(language);
|
|
482
|
+
}
|
|
483
|
+
clientRouter.navigate(href);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
document.addEventListener('click', clickHandler);
|
|
487
|
+
cleanupFns.push(() => document.removeEventListener('click', clickHandler));
|
|
488
|
+
// setup prefetching with cleanup
|
|
489
|
+
const prefetchCleanup = setupPrefetching();
|
|
490
|
+
cleanupFns.push(prefetchCleanup);
|
|
491
|
+
// return cleanup function
|
|
492
|
+
return () => cleanupFns.forEach((fn) => fn());
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* setup link prefetching
|
|
497
|
+
* @returns {Function} Cleanup function
|
|
498
|
+
*/
|
|
499
|
+
const prefetchedUrls = new Set();
|
|
500
|
+
function setupPrefetching() {
|
|
501
|
+
const observedTwinks = new Set();
|
|
502
|
+
const observer = new IntersectionObserver((entries) => {
|
|
503
|
+
entries.forEach((entry) => {
|
|
504
|
+
if (entry.isIntersecting) {
|
|
505
|
+
const link = entry.target;
|
|
506
|
+
const prefetchMode = link.getAttribute('data-prefetch');
|
|
507
|
+
const href = link.getAttribute('href');
|
|
508
|
+
if (prefetchMode === 'visible' && href && !prefetchedUrls.has(href)) prefetchUrl(href);
|
|
509
|
+
} else {
|
|
510
|
+
// unobserve links that leave viewport
|
|
511
|
+
const link = entry.target;
|
|
512
|
+
if (!entry.isIntersecting && observedTwinks.has(link)) {
|
|
513
|
+
observer.unobserve(link);
|
|
514
|
+
observedTwinks.delete(link);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
const observeTwinks = () => {
|
|
520
|
+
// observe new links
|
|
521
|
+
document.querySelectorAll('a[data-prefetch="visible"]').forEach((link) => {
|
|
522
|
+
if (!observedTwinks.has(link)) {
|
|
523
|
+
observer.observe(link);
|
|
524
|
+
observedTwinks.add(link);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
// clean up removed links
|
|
528
|
+
observedTwinks.forEach((link) => {
|
|
529
|
+
if (!document.contains(link)) {
|
|
530
|
+
observer.unobserve(link);
|
|
531
|
+
observedTwinks.delete(link);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
observeTwinks();
|
|
536
|
+
const mutationObserver = new MutationObserver(observeTwinks);
|
|
537
|
+
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
|
538
|
+
const mouseoverHandler = (e) => {
|
|
539
|
+
const link = e.target.closest('a[data-prefetch="hover"]');
|
|
540
|
+
if (link) {
|
|
541
|
+
const href = link.getAttribute('href');
|
|
542
|
+
if (href && !prefetchedUrls.has(href)) {
|
|
543
|
+
prefetchUrl(href);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
document.addEventListener('mouseover', mouseoverHandler);
|
|
548
|
+
// return cleanup function
|
|
549
|
+
return () => {
|
|
550
|
+
observer.disconnect();
|
|
551
|
+
mutationObserver.disconnect();
|
|
552
|
+
observedTwinks.clear();
|
|
553
|
+
document.removeEventListener('mouseover', mouseoverHandler);
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* prefetch url
|
|
559
|
+
* @param {string} href - URL to prefetch
|
|
560
|
+
* @param {number} [timeout=3000] - Timeout in milliseconds
|
|
561
|
+
*/
|
|
562
|
+
async function prefetchUrl(href, timeout = 3000) {
|
|
563
|
+
if (!href || prefetchedUrls.has(href)) return;
|
|
564
|
+
prefetchedUrls.add(href);
|
|
565
|
+
const controller = new AbortController();
|
|
566
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
567
|
+
try {
|
|
568
|
+
await fetch(href, {
|
|
569
|
+
method: 'GET',
|
|
570
|
+
headers: { Accept: 'text/html' },
|
|
571
|
+
credentials: 'same-origin',
|
|
572
|
+
signal: controller.signal,
|
|
573
|
+
});
|
|
574
|
+
} catch (e) {
|
|
575
|
+
// only remove from cache if it's not an abort (which is expected)
|
|
576
|
+
if (e.name !== 'AbortError') {
|
|
577
|
+
prefetchedUrls.delete(href);
|
|
578
|
+
console.debug('Prefetch failed:', href, e.message);
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
clearTimeout(timeoutId);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* get current router
|
|
587
|
+
* @returns {Object|null} Router instance
|
|
588
|
+
*/
|
|
589
|
+
export function useRouter() {
|
|
590
|
+
return clientRouter;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* get i18n config
|
|
595
|
+
* @returns {Object|null} i18n configuration
|
|
596
|
+
*/
|
|
597
|
+
export function useI18nConfig() {
|
|
598
|
+
return i18nConfig || (typeof window !== 'undefined' ? window.__HONMOON_I18N_CONFIG__ : null);
|
|
599
|
+
}
|