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,161 @@
1
+ /**
2
+ * cumstack HMR (Hot Module Reload)
3
+ * built-in hmr for wrangler development
4
+ */
5
+
6
+ let hmrWebSocket = null;
7
+ let reconnectAttempts = 0;
8
+ let reconnectTimeout = null;
9
+ const maxReconnectAttempts = 10;
10
+
11
+ /**
12
+ * initialize hmr connection for development
13
+ * @returns {void}
14
+ */
15
+ export function initHMR() {
16
+ if (typeof window === 'undefined' || !globalThis.__ENVIRONMENT__?.includes('dev')) return;
17
+ const hmrPort = globalThis.__HMR_PORT__ || 8790;
18
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
19
+ const wsUrl = `${protocol}//${window.location.hostname}:${hmrPort}`;
20
+ connectHMR(wsUrl);
21
+ }
22
+
23
+ /**
24
+ * establish websocket connection to HMR server
25
+ * @param {string} wsUrl - WebSocket URL
26
+ * @returns {void}
27
+ */
28
+ function connectHMR(wsUrl) {
29
+ try {
30
+ hmrWebSocket = new WebSocket(wsUrl);
31
+ hmrWebSocket.addEventListener('open', () => {
32
+ console.log('[WS] Established connection to HMR server');
33
+ reconnectAttempts = 0;
34
+ if (reconnectTimeout) {
35
+ clearTimeout(reconnectTimeout);
36
+ reconnectTimeout = null;
37
+ }
38
+ });
39
+
40
+ hmrWebSocket.addEventListener('message', (event) => {
41
+ const data = JSON.parse(event.data);
42
+ if (data.type === 'server-update') handleServerUpdate(data);
43
+ else if (data.type === 'full-reload') window.location.reload();
44
+ else if (data.type === 'css-update') handleCSSUpdate(data);
45
+ else if (data.type === 'js-update') handleJSUpdate(data);
46
+ else if (data.type === 'reload') window.location.reload();
47
+ else if (data.type === 'update') handleHotUpdate(data);
48
+ });
49
+
50
+ hmrWebSocket.addEventListener('close', () => {
51
+ console.log('[WS] Connection closed');
52
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
53
+ // debounced reconnection
54
+ if (reconnectAttempts < maxReconnectAttempts) {
55
+ reconnectAttempts++;
56
+ const delay = Math.min(1000 * reconnectAttempts, 5000);
57
+ console.log(`[WS] Reconnecting...`);
58
+ reconnectTimeout = setTimeout(() => {
59
+ reconnectTimeout = null;
60
+ connectHMR(wsUrl);
61
+ }, delay);
62
+ }
63
+ });
64
+ hmrWebSocket.addEventListener('error', (error) => console.error('[HMR] WebSocket error:', error));
65
+ } catch (error) {
66
+ console.error('[HMR] Failed to connect:', error);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * handle server update from HMR
72
+ * @param {Object} data - Update data
73
+ * @returns {void}
74
+ */
75
+ function handleServerUpdate(data) {
76
+ const currentTimestamp = window.__BUILD_TIMESTAMP__;
77
+ const pollForNewBuild = (attempt = 1, maxAttempts = 40) => {
78
+ fetch(window.location.href, { headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } })
79
+ .then((response) => response.text())
80
+ .then((html) => {
81
+ // extract build timestamp from new html
82
+ const timestampMatch = html.match(/window\.__BUILD_TIMESTAMP__\s*=\s*(\d+)/);
83
+ const newTimestamp = timestampMatch ? parseInt(timestampMatch[1]) : null;
84
+ if (!newTimestamp || newTimestamp === currentTimestamp) {
85
+ // build not ready yet, poll
86
+ if (attempt < maxAttempts) setTimeout(() => pollForNewBuild(attempt + 1, maxAttempts), 100);
87
+ else window.location.reload();
88
+ return;
89
+ }
90
+ const parser = new DOMParser();
91
+ const newDoc = parser.parseFromString(html, 'text/html');
92
+ const newApp = newDoc.getElementById('app');
93
+ const currentApp = document.getElementById('app');
94
+ if (newApp && currentApp) {
95
+ currentApp.innerHTML = newApp.innerHTML;
96
+ // update build timestamp
97
+ window.__BUILD_TIMESTAMP__ = newTimestamp;
98
+ // re-initialize client-side components
99
+ if (window.initComponents) window.initComponents();
100
+ }
101
+ })
102
+ .catch((error) => {
103
+ if (attempt < maxAttempts) setTimeout(() => pollForNewBuild(attempt + 1, maxAttempts), 100);
104
+ else {
105
+ console.error('[HMR] Failed to fetch updated HTML:', error);
106
+ window.location.reload();
107
+ }
108
+ });
109
+ };
110
+ // small delay to let wrangler start reloading, then start polling
111
+ setTimeout(() => pollForNewBuild(), 150);
112
+ }
113
+
114
+ /**
115
+ * handle hot module update
116
+ * @param {Object} data - Update data
117
+ * @returns {void}
118
+ */
119
+ function handleHotUpdate(data) {
120
+ window.location.reload();
121
+ }
122
+
123
+ /**
124
+ * handle CSS update from HMR
125
+ * @param {Object} data - Update data with timestamp
126
+ * @returns {void}
127
+ */
128
+ function handleCSSUpdate(data) {
129
+ const links = document.querySelectorAll('link[rel="stylesheet"]');
130
+ links.forEach((link) => {
131
+ const href = link.href;
132
+ const url = new URL(href);
133
+ // add timestamp to bust cache
134
+ url.searchParams.set('t', data.timestamp);
135
+ link.href = url.toString();
136
+ });
137
+ }
138
+
139
+ /**
140
+ * handle JavaScript update from HMR
141
+ * @param {Object} data - Update data
142
+ * @returns {void}
143
+ */
144
+ function handleJSUpdate(data) {
145
+ window.location.reload();
146
+ }
147
+
148
+ /**
149
+ * dispose hmr connection and cleanup
150
+ * @returns {void}
151
+ */
152
+ export function disposeHMR() {
153
+ if (reconnectTimeout) {
154
+ clearTimeout(reconnectTimeout);
155
+ reconnectTimeout = null;
156
+ }
157
+ if (hmrWebSocket) {
158
+ hmrWebSocket.close();
159
+ hmrWebSocket = null;
160
+ }
161
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * cumstack Client
3
+ * client-side rendering and hydration
4
+ */
5
+
6
+ import { setLanguage, detectBrowserLanguage, clearPreferredLanguage, getUserLanguage } from '../shared/i18n.js';
7
+ import { initComponents } from './components.js';
8
+ import { initHMR } from './hmr.js';
9
+
10
+ export { Twink } from './Twink.js';
11
+ export { configureHydration } from '../client.js';
12
+
13
+ /**
14
+ * get environment variable (client-side)
15
+ * @param {string} key - environment variable name
16
+ * @param {string} [fallback] - fallback value if not found
17
+ * @returns {string|undefined} environment variable value
18
+ */
19
+ export function env(key, fallback) {
20
+ if (typeof window === 'undefined') return fallback;
21
+ const envVars = window.__ENV__ || {};
22
+ return envVars[key] ?? fallback;
23
+ }
24
+
25
+ /**
26
+ * client entry wrapper component
27
+ * @param {Object} props - Component props
28
+ * @param {*} props.children - Child elements
29
+ * @returns {*} Children
30
+ */
31
+ export function CowgirlCreampie({ children }) {
32
+ return children || null;
33
+ }
34
+
35
+ /**
36
+ * mount and hydrate the app (client-side initialization)
37
+ * @param {Function} app - App component function
38
+ * @param {HTMLElement|string} container - Container element or selector
39
+ * @returns {void}
40
+ */
41
+ export function cowgirl(app, container) {
42
+ initHMR();
43
+ if (typeof window !== 'undefined') window.initComponents = initComponents;
44
+ const pathSegments = window.location.pathname.split('/').filter(Boolean);
45
+ let language = 'en';
46
+ let isExplicitRoute = false;
47
+
48
+ if (pathSegments.length > 0 && pathSegments[0].length === 2) {
49
+ // LANG ROUTE - (e.g., /en/, /sv/about)
50
+ language = pathSegments[0];
51
+ isExplicitRoute = true;
52
+ } else {
53
+ // ROOT ROUTE - check for user language first, then use auto-detection
54
+ clearPreferredLanguage();
55
+ const userLang = getUserLanguage();
56
+ if (userLang) language = userLang;
57
+ else language = detectBrowserLanguage();
58
+ }
59
+ setLanguage(language, isExplicitRoute);
60
+ setupNavigation();
61
+ initComponents();
62
+ }
63
+
64
+ /**
65
+ * render virtual node to DOM element
66
+ * @param {*} vnode - Virtual node to render
67
+ * @param {HTMLElement} container - Container element
68
+ * @returns {void}
69
+ */
70
+ function renderElement(vnode, container) {
71
+ if (vnode == null || vnode === false || vnode === true) return;
72
+ if (typeof vnode === 'string' || typeof vnode === 'number') {
73
+ container.appendChild(document.createTextNode(vnode));
74
+ return;
75
+ }
76
+ if (Array.isArray(vnode)) {
77
+ vnode.forEach((child) => renderElement(child, container));
78
+ return;
79
+ }
80
+ if (typeof vnode === 'function') {
81
+ renderElement(vnode(), container);
82
+ return;
83
+ }
84
+ if (vnode.type === 'fragment') {
85
+ vnode.children?.forEach((child) => renderElement(child, container));
86
+ return;
87
+ }
88
+ if (typeof vnode.type === 'function') {
89
+ const result = vnode.type(vnode.props || {});
90
+ renderElement(result, container);
91
+ return;
92
+ }
93
+
94
+ const element = document.createElement(vnode.type);
95
+
96
+ // set props
97
+ if (vnode.props) {
98
+ Object.entries(vnode.props).forEach(([key, value]) => {
99
+ if (key === 'children') return;
100
+ if (key.startsWith('on') && typeof value === 'function') {
101
+ const eventName = key.slice(2).toLowerCase();
102
+ element.addEventListener(eventName, value);
103
+ } else if (key === 'className') element.className = value;
104
+ else if (key === 'style' && typeof value === 'object') Object.assign(element.style, value);
105
+ else if (key === 'dangerouslySetInnerHTML') element.innerHTML = value.__html;
106
+ else element.setAttribute(key, value);
107
+ });
108
+ }
109
+ // render children
110
+ if (vnode.children) vnode.children.forEach((child) => renderElement(child, element));
111
+ container.appendChild(element);
112
+ }
113
+
114
+ /**
115
+ * setup SPA navigation with language route handling
116
+ * @returns {void}
117
+ */
118
+ function setupNavigation() {
119
+ // update all spa links to include language prefix if in a language route
120
+ function updateTwinksForLanguage() {
121
+ const inLanguageRoute = window.location.pathname.match(/^\/([a-z]{2})(?:\/|$)/);
122
+ if (!inLanguageRoute) return;
123
+ const lang = inLanguageRoute[1];
124
+ document.querySelectorAll('a[data-spa-link]').forEach((link) => {
125
+ const href = link.getAttribute('href');
126
+ if (!href || href.startsWith('http') || href.startsWith('//')) return;
127
+ // if href doesn't already have language prefix, add it
128
+ if (!href.match(/^\/[a-z]{2}(?:\/|$)/)) link.setAttribute('href', `/${lang}${href === '/' ? '' : href}`);
129
+ });
130
+ }
131
+
132
+ // update on initial load
133
+ updateTwinksForLanguage();
134
+
135
+ document.addEventListener('click', (e) => {
136
+ const link = e.target.closest('a[data-spa-link]');
137
+ if (!link) return;
138
+ const href = link.getAttribute('href');
139
+ if (!href || href.startsWith('http') || href.startsWith('//')) return;
140
+ e.preventDefault();
141
+ window.history.pushState({}, '', href);
142
+ window.location.reload();
143
+ });
144
+ }