@webstir-io/webstir 0.1.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.
Files changed (123) hide show
  1. package/README.md +69 -0
  2. package/assets/features/client_nav/client_nav.ts +469 -0
  3. package/assets/features/content_nav/content_nav.css +170 -0
  4. package/assets/features/content_nav/content_nav.ts +358 -0
  5. package/assets/features/router/router-types.ts +6 -0
  6. package/assets/features/router/router.ts +118 -0
  7. package/assets/features/search/search.css +204 -0
  8. package/assets/features/search/search.ts +627 -0
  9. package/assets/templates/api/src/backend/index.ts +13 -0
  10. package/assets/templates/api/src/backend/tsconfig.json +15 -0
  11. package/assets/templates/api/src/shared/router-types.ts +23 -0
  12. package/assets/templates/api/src/shared/tsconfig.json +10 -0
  13. package/assets/templates/api/src/shared/types/index.ts +4 -0
  14. package/assets/templates/full/src/backend/index.ts +13 -0
  15. package/assets/templates/full/src/backend/tsconfig.json +15 -0
  16. package/assets/templates/full/src/frontend/app/app.css +65 -0
  17. package/assets/templates/full/src/frontend/app/app.html +13 -0
  18. package/assets/templates/full/src/frontend/app/app.ts +188 -0
  19. package/assets/templates/full/src/frontend/app/error.ts +127 -0
  20. package/assets/templates/full/src/frontend/app/hmr.js +355 -0
  21. package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
  22. package/assets/templates/full/src/frontend/app/refresh.js +114 -0
  23. package/assets/templates/full/src/frontend/app/router.ts +126 -0
  24. package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
  25. package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
  26. package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
  27. package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
  28. package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
  29. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
  30. package/assets/templates/full/src/frontend/tsconfig.json +20 -0
  31. package/assets/templates/full/src/shared/router-types.ts +23 -0
  32. package/assets/templates/full/src/shared/tsconfig.json +10 -0
  33. package/assets/templates/full/src/shared/types/index.ts +4 -0
  34. package/assets/templates/shared/Errors.404.html +23 -0
  35. package/assets/templates/shared/Errors.500.html +23 -0
  36. package/assets/templates/shared/Errors.default.html +23 -0
  37. package/assets/templates/shared/types/global.d.ts +32 -0
  38. package/assets/templates/shared/types.global.d.ts +32 -0
  39. package/assets/templates/spa/src/frontend/app/app.css +65 -0
  40. package/assets/templates/spa/src/frontend/app/app.html +13 -0
  41. package/assets/templates/spa/src/frontend/app/app.ts +188 -0
  42. package/assets/templates/spa/src/frontend/app/error.ts +127 -0
  43. package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
  44. package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
  45. package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
  46. package/assets/templates/spa/src/frontend/app/router.ts +126 -0
  47. package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
  48. package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
  49. package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
  50. package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
  51. package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
  52. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
  53. package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
  54. package/assets/templates/spa/src/shared/router-types.ts +23 -0
  55. package/assets/templates/spa/src/shared/tsconfig.json +10 -0
  56. package/assets/templates/spa/src/shared/types/index.ts +4 -0
  57. package/assets/templates/ssg/src/frontend/app/app.css +12 -0
  58. package/assets/templates/ssg/src/frontend/app/app.html +43 -0
  59. package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
  60. package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
  61. package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
  62. package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
  63. package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
  64. package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
  65. package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
  66. package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
  67. package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
  68. package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
  69. package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
  70. package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
  71. package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
  72. package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
  73. package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
  74. package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
  75. package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
  76. package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
  77. package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
  78. package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
  79. package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
  80. package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
  81. package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
  82. package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
  83. package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
  84. package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
  85. package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
  86. package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
  87. package/package.json +41 -0
  88. package/scripts/pack-standalone.mjs +127 -0
  89. package/scripts/sync-assets.mjs +87 -0
  90. package/src/add-backend.ts +164 -0
  91. package/src/add.ts +112 -0
  92. package/src/api-watch.ts +84 -0
  93. package/src/backend-inspect.ts +45 -0
  94. package/src/backend-runtime.ts +286 -0
  95. package/src/build-plan.ts +12 -0
  96. package/src/build.ts +10 -0
  97. package/src/cli.ts +569 -0
  98. package/src/compile-tests.ts +61 -0
  99. package/src/dev-server.ts +393 -0
  100. package/src/enable-assets.ts +196 -0
  101. package/src/enable.ts +477 -0
  102. package/src/execute.ts +85 -0
  103. package/src/format.ts +254 -0
  104. package/src/frontend-watch.ts +145 -0
  105. package/src/full-watch.ts +80 -0
  106. package/src/index.ts +20 -0
  107. package/src/init-assets.ts +96 -0
  108. package/src/init.ts +339 -0
  109. package/src/paths.ts +26 -0
  110. package/src/providers.ts +88 -0
  111. package/src/publish.ts +8 -0
  112. package/src/refresh.ts +56 -0
  113. package/src/repair.ts +414 -0
  114. package/src/runtime.ts +48 -0
  115. package/src/smoke.ts +161 -0
  116. package/src/stop-signal.ts +26 -0
  117. package/src/test.ts +215 -0
  118. package/src/types.ts +29 -0
  119. package/src/watch-daemon-client.ts +171 -0
  120. package/src/watch-events.ts +195 -0
  121. package/src/watch.ts +66 -0
  122. package/src/workspace-watcher.ts +251 -0
  123. package/src/workspace.ts +55 -0
@@ -0,0 +1,355 @@
1
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
2
+ console.warn('[webstir-hmr] Browser runtime not detected; hot updates disabled.');
3
+ } else if (typeof EventSource === 'undefined') {
4
+ console.warn('[webstir-hmr] EventSource API unavailable; falling back to full reloads.');
5
+ } else {
6
+ const eventSource = getOrCreateEventSource();
7
+ const updateQueue = [];
8
+ let applyingUpdate = false;
9
+ let reloadScheduled = false;
10
+
11
+ eventSource.addEventListener('hmr', (event) => {
12
+ if (!event || !event.data) {
13
+ return;
14
+ }
15
+
16
+ try {
17
+ const payload = JSON.parse(event.data);
18
+ enqueueHotUpdate(payload);
19
+ } catch (error) {
20
+ console.error('[webstir-hmr] Failed to parse hot update payload.', error);
21
+ requestReload('payload.parse');
22
+ }
23
+ });
24
+
25
+ function enqueueHotUpdate(payload) {
26
+ updateQueue.push(payload);
27
+ void processQueue();
28
+ }
29
+
30
+ async function processQueue() {
31
+ if (applyingUpdate || reloadScheduled || updateQueue.length === 0) {
32
+ return;
33
+ }
34
+
35
+ const payload = updateQueue.shift();
36
+ if (!payload) {
37
+ return;
38
+ }
39
+
40
+ applyingUpdate = true;
41
+ const result = await applyHotUpdate(payload).catch((error) => ({
42
+ success: false,
43
+ reason: 'runtime.error',
44
+ error
45
+ }));
46
+ applyingUpdate = false;
47
+
48
+ if (!result.success) {
49
+ requestReload(result.reason, result.error, payload, result.details);
50
+ return;
51
+ }
52
+
53
+ if (updateQueue.length > 0) {
54
+ await processQueue();
55
+ }
56
+ }
57
+
58
+ async function applyHotUpdate(payload) {
59
+ if (!payload || typeof payload !== 'object') {
60
+ return { success: false, reason: 'payload.invalid' };
61
+ }
62
+
63
+ if (payload.requiresReload) {
64
+ return { success: false, reason: 'payload.requiresReload' };
65
+ }
66
+
67
+ const modules = Array.isArray(payload.modules) ? payload.modules : [];
68
+ const styles = Array.isArray(payload.styles) ? payload.styles : [];
69
+ const cacheBuster = Date.now().toString(36);
70
+ const baseContext = {
71
+ changedFile: payload.changedFile ?? null,
72
+ modules,
73
+ styles,
74
+ cacheBuster,
75
+ timestamp: Date.now()
76
+ };
77
+
78
+ if (modules.length === 0 && styles.length === 0) {
79
+ console.info('[webstir-hmr] Received hot update with no changes.');
80
+ return { success: true };
81
+ }
82
+
83
+ const moduleResult = await applyModuleChanges(modules, baseContext);
84
+ if (!moduleResult.success) {
85
+ return moduleResult;
86
+ }
87
+
88
+ const styleResult = await applyStyleChanges(styles, baseContext);
89
+ if (!styleResult.success) {
90
+ return styleResult;
91
+ }
92
+
93
+ const changedFile = baseContext.changedFile ?? 'unknown';
94
+ console.info(`[webstir-hmr] Applied hot update for ${changedFile}.`);
95
+
96
+ return { success: true };
97
+ }
98
+
99
+ async function applyModuleChanges(modules, baseContext) {
100
+ if (modules.length === 0) {
101
+ return { success: true };
102
+ }
103
+
104
+ for (const asset of modules) {
105
+ if (!isValidAsset(asset)) {
106
+ return { success: false, reason: 'module.invalid', details: asset };
107
+ }
108
+
109
+ const context = createModuleContext(baseContext, asset);
110
+
111
+ if (!(await invokeDispose(asset, context))) {
112
+ return { success: false, reason: 'module.dispose', details: asset };
113
+ }
114
+
115
+ const specifier = withCacheBuster(asset.url, baseContext.cacheBuster);
116
+ let moduleExports;
117
+ try {
118
+ moduleExports = await import(specifier);
119
+ } catch (error) {
120
+ console.error(`[webstir-hmr] Failed to import module '${asset.url}'.`, error);
121
+ return { success: false, reason: 'module.import', error, details: asset };
122
+ }
123
+
124
+ if (!(await invokeAccept(moduleExports, context))) {
125
+ console.warn(`[webstir-hmr] Accept handler declined update for '${asset.relativePath}'.`);
126
+ return { success: false, reason: 'module.declined', details: asset };
127
+ }
128
+ }
129
+
130
+ return { success: true };
131
+ }
132
+
133
+ async function applyStyleChanges(styles, baseContext) {
134
+ if (styles.length === 0) {
135
+ return { success: true };
136
+ }
137
+
138
+ for (const asset of styles) {
139
+ if (!isValidAsset(asset)) {
140
+ return { success: false, reason: 'style.invalid', details: asset };
141
+ }
142
+
143
+ const success = await swapStylesheet(asset, baseContext.cacheBuster);
144
+ if (!success) {
145
+ return { success: false, reason: 'style.swap', details: asset };
146
+ }
147
+ }
148
+
149
+ return { success: true };
150
+ }
151
+
152
+ function createModuleContext(baseContext, asset) {
153
+ return {
154
+ changedFile: baseContext.changedFile,
155
+ modules: baseContext.modules,
156
+ styles: baseContext.styles,
157
+ cacheBuster: baseContext.cacheBuster,
158
+ timestamp: baseContext.timestamp,
159
+ asset
160
+ };
161
+ }
162
+
163
+ async function invokeDispose(asset, context) {
164
+ const handler = window.__webstirDispose;
165
+ if (typeof handler !== 'function') {
166
+ return true;
167
+ }
168
+
169
+ try {
170
+ const result = handler(asset, context);
171
+ if (isPromise(result)) {
172
+ await result;
173
+ }
174
+ return true;
175
+ } catch (error) {
176
+ console.error(`[webstir-hmr] Dispose handler threw for '${asset.relativePath}'.`, error);
177
+ return false;
178
+ }
179
+ }
180
+
181
+ async function invokeAccept(moduleExports, context) {
182
+ const handler = window.__webstirAccept;
183
+ if (typeof handler !== 'function') {
184
+ return true;
185
+ }
186
+
187
+ try {
188
+ const result = handler(moduleExports, context);
189
+ if (isPromise(result)) {
190
+ const resolved = await result;
191
+ return resolved !== false;
192
+ }
193
+ return result !== false;
194
+ } catch (error) {
195
+ console.error('[webstir-hmr] Accept handler threw.', error);
196
+ return false;
197
+ }
198
+ }
199
+
200
+ function swapStylesheet(asset, cacheBuster) {
201
+ return new Promise((resolve) => {
202
+ const specifier = withCacheBuster(asset.url, cacheBuster);
203
+ const existingLinks = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
204
+ const target = existingLinks.find((link) => normalizePath(link.href) === normalizePath(asset.url));
205
+ const replacement = document.createElement('link');
206
+ replacement.rel = 'stylesheet';
207
+ replacement.href = specifier;
208
+
209
+ replacement.addEventListener('load', () => {
210
+ if (target && target.parentNode) {
211
+ requestAnimationFrame(() => target.remove());
212
+ }
213
+ resolve(true);
214
+ });
215
+
216
+ replacement.addEventListener('error', () => {
217
+ replacement.remove();
218
+ resolve(false);
219
+ });
220
+
221
+ if (target && target.parentNode) {
222
+ target.after(replacement);
223
+ } else {
224
+ document.head.appendChild(replacement);
225
+ }
226
+ });
227
+ }
228
+
229
+ function requestReload(reason, error, payload, details) {
230
+ if (reloadScheduled) {
231
+ return;
232
+ }
233
+
234
+ reloadScheduled = true;
235
+
236
+ if (error) {
237
+ console.error('[webstir-hmr] Hot update failed.', error);
238
+ }
239
+
240
+ const changedFile = payload?.changedFile ?? 'unknown';
241
+ const fallbackReasons = Array.isArray(payload?.fallbackReasons) && payload.fallbackReasons.length > 0
242
+ ? ` Fallback reasons: ${payload.fallbackReasons.join(', ')}.`
243
+ : '';
244
+ console.warn(
245
+ `[webstir-hmr] Falling back to full reload for ${changedFile}. ` +
246
+ `Reason: ${reason ?? 'unknown'}.${fallbackReasons}`
247
+ );
248
+
249
+ setStatus('hmr-fallback', 'Hot update fallback – reloading…');
250
+ notifyFallback(reason, payload, details);
251
+ updateQueue.length = 0;
252
+ setTimeout(() => window.location.reload(), 0);
253
+ }
254
+
255
+ function setStatus(status, message) {
256
+ const setter = window.__webstirSetDevStatus;
257
+ if (typeof setter === 'function') {
258
+ try {
259
+ setter(status, message);
260
+ } catch (error) {
261
+ console.debug('[webstir-hmr] Status handler failed.', error);
262
+ }
263
+ }
264
+ }
265
+
266
+ function notifyFallback(reason, payload, details) {
267
+ const handler = window.__webstirOnHmrFallback;
268
+ if (typeof handler === 'function') {
269
+ try {
270
+ handler({ reason, payload, details });
271
+ } catch (error) {
272
+ console.debug('[webstir-hmr] Fallback hook threw.', error);
273
+ }
274
+ }
275
+ }
276
+
277
+ function readStats(candidate) {
278
+ if (!candidate || typeof candidate !== 'object') {
279
+ return null;
280
+ }
281
+
282
+ const hotUpdates = coerceInteger(candidate.hotUpdates);
283
+ const reloadFallbacks = coerceInteger(candidate.reloadFallbacks);
284
+
285
+ if (hotUpdates === null || reloadFallbacks === null) {
286
+ return null;
287
+ }
288
+
289
+ return {
290
+ hotUpdates,
291
+ reloadFallbacks
292
+ };
293
+ }
294
+
295
+ function coerceInteger(value) {
296
+ if (typeof value === 'number' && Number.isFinite(value)) {
297
+ return Math.trunc(value);
298
+ }
299
+
300
+ if (typeof value === 'string') {
301
+ const parsed = Number.parseInt(value, 10);
302
+ if (Number.isFinite(parsed)) {
303
+ return parsed;
304
+ }
305
+ }
306
+
307
+ return null;
308
+ }
309
+
310
+ function getOrCreateEventSource() {
311
+ if (window.__webstirEventSource instanceof EventSource) {
312
+ return window.__webstirEventSource;
313
+ }
314
+
315
+ const source = new EventSource('/sse');
316
+ window.__webstirEventSource = source;
317
+ return source;
318
+ }
319
+
320
+ function withCacheBuster(url, cacheBuster) {
321
+ if (typeof url !== 'string' || url.length === 0) {
322
+ return url;
323
+ }
324
+
325
+ try {
326
+ const parsed = new URL(url, window.location.origin);
327
+ parsed.searchParams.set('hmr', cacheBuster);
328
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
329
+ } catch {
330
+ const separator = url.includes('?') ? '&' : '?';
331
+ return `${url}${separator}hmr=${cacheBuster}`;
332
+ }
333
+ }
334
+
335
+ function normalizePath(url) {
336
+ if (typeof url !== 'string') {
337
+ return '';
338
+ }
339
+
340
+ try {
341
+ return new URL(url, window.location.origin).pathname;
342
+ } catch {
343
+ const index = url.indexOf('?');
344
+ return index === -1 ? url : url.slice(0, index);
345
+ }
346
+ }
347
+
348
+ function isValidAsset(asset) {
349
+ return Boolean(asset && typeof asset.url === 'string' && asset.url.length > 0);
350
+ }
351
+
352
+ function isPromise(value) {
353
+ return !!value && typeof value.then === 'function';
354
+ }
355
+ }
@@ -0,0 +1,8 @@
1
+ import { startRouter } from './router.js';
2
+
3
+ export function navigate(url: string) {
4
+ return startRouter().navigate(url);
5
+ }
6
+
7
+ export { startRouter } from './router.js';
8
+ export type { RouteHandler, RouteParams } from '@shared/router-types.js';
@@ -0,0 +1,114 @@
1
+ const existingEventSource = window.__webstirEventSource;
2
+ const eventSource = existingEventSource instanceof EventSource
3
+ ? existingEventSource
4
+ : new EventSource('/sse');
5
+ window.__webstirEventSource = eventSource;
6
+ let isShuttingDown = false;
7
+ let resetTimer;
8
+
9
+ const indicator = document.createElement('div');
10
+ indicator.id = 'dev-server-indicator';
11
+ indicator.style.cssText = `
12
+ position: fixed;
13
+ bottom: 20px;
14
+ right: 20px;
15
+ color: white;
16
+ padding: 12px 16px;
17
+ border-radius: 20px;
18
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
19
+ font-size: 12px;
20
+ font-weight: 500;
21
+ z-index: 10000;
22
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
23
+ opacity: 0;
24
+ transition: opacity 0.3s ease;
25
+ `;
26
+
27
+ document.body.appendChild(indicator);
28
+
29
+ function updateIndicator(background, text, shouldReset = false) {
30
+ indicator.style.opacity = '1';
31
+ indicator.style.background = background;
32
+ indicator.textContent = text;
33
+
34
+ if (resetTimer) {
35
+ clearTimeout(resetTimer);
36
+ resetTimer = undefined;
37
+ }
38
+
39
+ if (shouldReset) {
40
+ resetTimer = setTimeout(setConnected, 1500);
41
+ }
42
+ }
43
+
44
+ function setConnected(message) {
45
+ updateIndicator('#4CAF50', message ?? '● Dev Server Connected');
46
+ }
47
+
48
+ function setDisconnected(message) {
49
+ updateIndicator('#f44336', message ?? 'Dev Server Disconnected');
50
+ }
51
+
52
+ function setBuilding(message) {
53
+ updateIndicator('#FF9800', message ?? '● Rebuilding…');
54
+ }
55
+
56
+ function setBuildSuccess(message) {
57
+ updateIndicator('#4CAF50', message ?? '● Rebuild Complete', true);
58
+ }
59
+
60
+ function setBuildFailure(message) {
61
+ updateIndicator('#f44336', message ?? '● Build Failed');
62
+ }
63
+
64
+ function setHmrFallback(message) {
65
+ updateIndicator('#FF5722', message ?? '● Reloading (HMR fallback)…');
66
+ }
67
+
68
+ const statusHandlers = {
69
+ connected: setConnected,
70
+ disconnected: setDisconnected,
71
+ building: setBuilding,
72
+ success: setBuildSuccess,
73
+ error: setBuildFailure,
74
+ 'hmr-fallback': setHmrFallback
75
+ };
76
+
77
+ function applyStatus(status, message) {
78
+ const handler = statusHandlers[status];
79
+ if (typeof handler === 'function') {
80
+ handler(message);
81
+ }
82
+ }
83
+
84
+ window.__webstirSetDevStatus = applyStatus;
85
+
86
+ eventSource.onopen = () => {
87
+ console.log('SSE connection established.');
88
+ applyStatus('connected');
89
+ };
90
+
91
+ eventSource.onmessage = (event) => {
92
+ if (event.data === 'reload') {
93
+ location.reload();
94
+ } else if (event.data === 'shutdown') {
95
+ isShuttingDown = true;
96
+ setDisconnected();
97
+ eventSource.close();
98
+ }
99
+ };
100
+
101
+ eventSource.addEventListener('status', (event) => {
102
+ applyStatus(event.data);
103
+ });
104
+
105
+ eventSource.onerror = (error) => {
106
+ if (!isShuttingDown) {
107
+ console.error('SSE error:', error);
108
+ applyStatus('disconnected');
109
+ }
110
+ };
111
+
112
+ window.addEventListener('beforeunload', function () {
113
+ eventSource.close();
114
+ });
@@ -0,0 +1,126 @@
1
+ import type { RouteHandler, RouteParams, RoutingMetadata } from '@shared/router-types.js';
2
+
3
+ export class Router {
4
+ private routes = new Map<string, RouteHandler>();
5
+ private currentHandler: RouteHandler | null = null;
6
+
7
+ constructor() {
8
+ this.loadRoutingMetadataFromDom();
9
+ this.setupBrowserNavigation();
10
+ this.interceptLinkClicks();
11
+ }
12
+
13
+ registerRoute(path: string, handler: RouteHandler) {
14
+ this.routes.set(path, handler);
15
+ }
16
+
17
+ async navigate(url: string) {
18
+ const targetPath = new URL(url, window.location.origin).pathname;
19
+ const routeHandler = this.routes.get(targetPath);
20
+
21
+ if (!routeHandler) {
22
+ window.location.href = url;
23
+ return;
24
+ }
25
+
26
+ window.history.pushState({}, '', url);
27
+ await this.handleRouteChange();
28
+ }
29
+
30
+ private loadRoutingMetadataFromDom() {
31
+ const metadataElement = document.getElementById('app-routing-metadata');
32
+ if (!metadataElement?.textContent) return;
33
+
34
+ try {
35
+ const metadata: RoutingMetadata = JSON.parse(metadataElement.textContent);
36
+ } catch (error) {
37
+ console.warn('Failed to parse routing metadata:', error);
38
+ }
39
+ }
40
+
41
+ private setupBrowserNavigation() {
42
+ window.addEventListener('popstate', () => {
43
+ this.handleRouteChange();
44
+ });
45
+ }
46
+
47
+ private interceptLinkClicks() {
48
+ document.addEventListener('click', (event) => {
49
+ const target = event.target as EventTarget | null;
50
+
51
+ // Only handle element targets; respect modifier keys and prior handlers
52
+ if (!(target instanceof Element)) return;
53
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
54
+
55
+ const clickedLink = target.closest('a');
56
+ if (!clickedLink || !this.shouldInterceptLink(clickedLink as HTMLAnchorElement)) return;
57
+
58
+ event.preventDefault();
59
+ this.navigate((clickedLink as HTMLAnchorElement).href);
60
+ });
61
+ }
62
+
63
+ private async handleRouteChange() {
64
+ const currentPath = window.location.pathname;
65
+ const routeParams = this.extractQueryParams();
66
+
67
+ await this.callOnLeaveHandler();
68
+
69
+ const newHandler = this.routes.get(currentPath);
70
+ if (newHandler) {
71
+ this.currentHandler = newHandler;
72
+ await this.callOnEnterHandler(newHandler, routeParams);
73
+ }
74
+ }
75
+
76
+ private async callOnLeaveHandler() {
77
+ if (this.currentHandler?.onLeave) {
78
+ await this.currentHandler.onLeave();
79
+ }
80
+ }
81
+
82
+ private async callOnEnterHandler(handler: RouteHandler, params: RouteParams) {
83
+ if (handler.onEnter) {
84
+ await handler.onEnter(params);
85
+ }
86
+ }
87
+
88
+ private shouldInterceptLink(link: HTMLAnchorElement): boolean {
89
+ const isExternalLink = link.origin !== window.location.origin;
90
+ const isDownloadLink = link.hasAttribute('download');
91
+ const opensInNewTab = link.getAttribute('target') === '_blank';
92
+ const hasRouteHandler = this.routes.has(link.pathname);
93
+
94
+ return !isExternalLink && !isDownloadLink && !opensInNewTab && hasRouteHandler;
95
+ }
96
+
97
+ private extractQueryParams(): RouteParams {
98
+ const params: RouteParams = {};
99
+ const urlSearchParams = new URLSearchParams(window.location.search);
100
+
101
+ urlSearchParams.forEach((value, key) => {
102
+ params[key] = value;
103
+ });
104
+
105
+ return params;
106
+ }
107
+ }
108
+
109
+ let singleton: Router | null = null;
110
+
111
+ export function startRouter(): Router {
112
+ if (!singleton) {
113
+ singleton = new Router();
114
+ }
115
+ return singleton;
116
+ }
117
+
118
+ export function getRouter(): Router | null {
119
+ return singleton;
120
+ }
121
+
122
+ export function navigate(url: string) {
123
+ return startRouter().navigate(url);
124
+ }
125
+
126
+ export type { RouteHandler, RouteParams };
@@ -0,0 +1,2 @@
1
+ /* Base styles placeholder (extend as needed) */
2
+ /* This file can be extended by projects if desired. */
@@ -0,0 +1,48 @@
1
+ /* Basic CSS Reset */
2
+ *, *::before, *::after {
3
+ box-sizing: border-box;
4
+ }
5
+
6
+ html, body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd {
7
+ margin: 0;
8
+ }
9
+
10
+ ul[role='list'], ol[role='list'] {
11
+ list-style: none;
12
+ }
13
+
14
+ html:focus-within {
15
+ scroll-behavior: smooth;
16
+ }
17
+
18
+ body {
19
+ min-height: 100vh;
20
+ text-rendering: optimizeSpeed;
21
+ line-height: 1.5;
22
+ }
23
+
24
+ a:not([class]) {
25
+ text-decoration-skip-ink: auto;
26
+ }
27
+
28
+ img, picture {
29
+ max-width: 100%;
30
+ display: block;
31
+ }
32
+
33
+ input, button, textarea, select {
34
+ font: inherit;
35
+ }
36
+
37
+ @media (prefers-reduced-motion: reduce) {
38
+ html:focus-within {
39
+ scroll-behavior: auto;
40
+ }
41
+
42
+ *, *::before, *::after {
43
+ animation-duration: 0.01ms !important;
44
+ animation-iteration-count: 1 !important;
45
+ transition-duration: 0.01ms !important;
46
+ scroll-behavior: auto !important;
47
+ }
48
+ }
@@ -0,0 +1,21 @@
1
+ /* Index Page Styles */
2
+ @import "@app/app.css";
3
+
4
+ .hero {
5
+ display: grid;
6
+ gap: 2rem;
7
+ align-items: center;
8
+ text-align: center;
9
+ }
10
+
11
+ .hero h1 {
12
+ font-size: clamp(2.5rem, 6vw, 4rem);
13
+ font-weight: 600;
14
+ }
15
+
16
+ .hero p {
17
+ font-size: 1.125rem;
18
+ color: var(--text-secondary);
19
+ margin: 0 auto;
20
+ max-width: 42ch;
21
+ }
@@ -0,0 +1,10 @@
1
+ <head>
2
+ <title>Home</title>
3
+ <link rel="stylesheet" href="index.css" />
4
+ <script type="module" src="index.js" async></script>
5
+ </head>
6
+ <body>
7
+ <main>
8
+ Home
9
+ </main>
10
+ </body>
@@ -0,0 +1,18 @@
1
+ // TypeScript file for index page
2
+
3
+ import { registerHotModule } from '../../app/app';
4
+
5
+ const main = document.querySelector('main');
6
+ if (main) {
7
+ main.dataset.hmrRendered = String(Date.now());
8
+ }
9
+
10
+ registerHotModule(import.meta.url, {
11
+ accept: (_, context) => {
12
+ console.info('[webstir-hmr] Home page accepted update for', context.asset?.relativePath ?? 'unknown module');
13
+ return true;
14
+ },
15
+ dispose: (context) => {
16
+ console.info('[webstir-hmr] Preparing to update', context.asset?.relativePath ?? 'home page module');
17
+ }
18
+ });