@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,370 @@
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 reloadMarkerKey = 'webstir-hmr-last-reload';
7
+ if (typeof sessionStorage !== 'undefined') {
8
+ const marker = sessionStorage.getItem(reloadMarkerKey);
9
+ if (marker) {
10
+ console.info(`[webstir-hmr] Last update required full reload.${marker}`);
11
+ sessionStorage.removeItem(reloadMarkerKey);
12
+ }
13
+ }
14
+
15
+ const eventSource = getOrCreateEventSource();
16
+ const updateQueue = [];
17
+ let applyingUpdate = false;
18
+ let reloadScheduled = false;
19
+
20
+ eventSource.addEventListener('hmr', (event) => {
21
+ if (!event || !event.data) {
22
+ return;
23
+ }
24
+
25
+ try {
26
+ const payload = JSON.parse(event.data);
27
+ enqueueHotUpdate(payload);
28
+ } catch (error) {
29
+ console.error('[webstir-hmr] Failed to parse hot update payload.', error);
30
+ requestReload('payload.parse');
31
+ }
32
+ });
33
+
34
+ function enqueueHotUpdate(payload) {
35
+ updateQueue.push(payload);
36
+ void processQueue();
37
+ }
38
+
39
+ async function processQueue() {
40
+ if (applyingUpdate || reloadScheduled || updateQueue.length === 0) {
41
+ return;
42
+ }
43
+
44
+ const payload = updateQueue.shift();
45
+ if (!payload) {
46
+ return;
47
+ }
48
+
49
+ applyingUpdate = true;
50
+ const result = await applyHotUpdate(payload).catch((error) => ({
51
+ success: false,
52
+ reason: 'runtime.error',
53
+ error
54
+ }));
55
+ applyingUpdate = false;
56
+
57
+ if (!result.success) {
58
+ requestReload(result.reason, result.error, payload, result.details);
59
+ return;
60
+ }
61
+
62
+ if (updateQueue.length > 0) {
63
+ await processQueue();
64
+ }
65
+ }
66
+
67
+ async function applyHotUpdate(payload) {
68
+ if (!payload || typeof payload !== 'object') {
69
+ return { success: false, reason: 'payload.invalid' };
70
+ }
71
+
72
+ if (payload.requiresReload) {
73
+ return { success: false, reason: 'payload.requiresReload' };
74
+ }
75
+
76
+ const modules = Array.isArray(payload.modules) ? payload.modules : [];
77
+ const styles = Array.isArray(payload.styles) ? payload.styles : [];
78
+ const cacheBuster = Date.now().toString(36);
79
+ const baseContext = {
80
+ changedFile: payload.changedFile ?? null,
81
+ modules,
82
+ styles,
83
+ cacheBuster,
84
+ timestamp: Date.now()
85
+ };
86
+
87
+ if (modules.length === 0 && styles.length === 0) {
88
+ console.info('[webstir-hmr] Received hot update with no changes.');
89
+ return { success: true };
90
+ }
91
+
92
+ const moduleResult = await applyModuleChanges(modules, baseContext);
93
+ if (!moduleResult.success) {
94
+ return moduleResult;
95
+ }
96
+
97
+ const styleResult = await applyStyleChanges(styles, baseContext);
98
+ if (!styleResult.success) {
99
+ return styleResult;
100
+ }
101
+
102
+ const changedFile = baseContext.changedFile ?? 'unknown';
103
+ console.info(`[webstir-hmr] Applied hot update for ${changedFile}.`);
104
+
105
+ return { success: true };
106
+ }
107
+
108
+ async function applyModuleChanges(modules, baseContext) {
109
+ if (modules.length === 0) {
110
+ return { success: true };
111
+ }
112
+
113
+ for (const asset of modules) {
114
+ if (!isValidAsset(asset)) {
115
+ return { success: false, reason: 'module.invalid', details: asset };
116
+ }
117
+
118
+ const context = createModuleContext(baseContext, asset);
119
+
120
+ if (!(await invokeDispose(asset, context))) {
121
+ return { success: false, reason: 'module.dispose', details: asset };
122
+ }
123
+
124
+ const specifier = withCacheBuster(asset.url, baseContext.cacheBuster);
125
+ let moduleExports;
126
+ try {
127
+ moduleExports = await import(specifier);
128
+ } catch (error) {
129
+ console.error(`[webstir-hmr] Failed to import module '${asset.url}'.`, error);
130
+ return { success: false, reason: 'module.import', error, details: asset };
131
+ }
132
+
133
+ if (!(await invokeAccept(moduleExports, context))) {
134
+ console.warn(`[webstir-hmr] Accept handler declined update for '${asset.relativePath}'.`);
135
+ return { success: false, reason: 'module.declined', details: asset };
136
+ }
137
+ }
138
+
139
+ return { success: true };
140
+ }
141
+
142
+ async function applyStyleChanges(styles, baseContext) {
143
+ if (styles.length === 0) {
144
+ return { success: true };
145
+ }
146
+
147
+ for (const asset of styles) {
148
+ if (!isValidAsset(asset)) {
149
+ return { success: false, reason: 'style.invalid', details: asset };
150
+ }
151
+
152
+ const success = await swapStylesheet(asset, baseContext.cacheBuster);
153
+ if (!success) {
154
+ return { success: false, reason: 'style.swap', details: asset };
155
+ }
156
+ }
157
+
158
+ return { success: true };
159
+ }
160
+
161
+ function createModuleContext(baseContext, asset) {
162
+ return {
163
+ changedFile: baseContext.changedFile,
164
+ modules: baseContext.modules,
165
+ styles: baseContext.styles,
166
+ cacheBuster: baseContext.cacheBuster,
167
+ timestamp: baseContext.timestamp,
168
+ asset
169
+ };
170
+ }
171
+
172
+ async function invokeDispose(asset, context) {
173
+ const handler = window.__webstirDispose;
174
+ if (typeof handler !== 'function') {
175
+ return true;
176
+ }
177
+
178
+ try {
179
+ const result = handler(asset, context);
180
+ if (isPromise(result)) {
181
+ await result;
182
+ }
183
+ return true;
184
+ } catch (error) {
185
+ console.error(`[webstir-hmr] Dispose handler threw for '${asset.relativePath}'.`, error);
186
+ return false;
187
+ }
188
+ }
189
+
190
+ async function invokeAccept(moduleExports, context) {
191
+ const handler = window.__webstirAccept;
192
+ if (typeof handler !== 'function') {
193
+ return true;
194
+ }
195
+
196
+ try {
197
+ const result = handler(moduleExports, context);
198
+ if (isPromise(result)) {
199
+ const resolved = await result;
200
+ return resolved !== false;
201
+ }
202
+ return result !== false;
203
+ } catch (error) {
204
+ console.error('[webstir-hmr] Accept handler threw.', error);
205
+ return false;
206
+ }
207
+ }
208
+
209
+ function swapStylesheet(asset, cacheBuster) {
210
+ return new Promise((resolve) => {
211
+ const specifier = withCacheBuster(asset.url, cacheBuster);
212
+ const existingLinks = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
213
+ const target = existingLinks.find((link) => normalizePath(link.href) === normalizePath(asset.url));
214
+ const replacement = document.createElement('link');
215
+ replacement.rel = 'stylesheet';
216
+ replacement.href = specifier;
217
+
218
+ replacement.addEventListener('load', () => {
219
+ if (target && target.parentNode) {
220
+ requestAnimationFrame(() => target.remove());
221
+ }
222
+ resolve(true);
223
+ });
224
+
225
+ replacement.addEventListener('error', () => {
226
+ replacement.remove();
227
+ resolve(false);
228
+ });
229
+
230
+ if (target && target.parentNode) {
231
+ target.after(replacement);
232
+ } else {
233
+ document.head.appendChild(replacement);
234
+ }
235
+ });
236
+ }
237
+
238
+ function requestReload(reason, error, payload, details) {
239
+ if (reloadScheduled) {
240
+ return;
241
+ }
242
+
243
+ reloadScheduled = true;
244
+
245
+ if (error) {
246
+ console.error('[webstir-hmr] Hot update failed.', error);
247
+ }
248
+
249
+ const changedFile = payload?.changedFile ?? 'unknown';
250
+ const fallbackReasons = Array.isArray(payload?.fallbackReasons) && payload.fallbackReasons.length > 0
251
+ ? ` Fallback reasons: ${payload.fallbackReasons.join(', ')}.`
252
+ : '';
253
+ console.warn(
254
+ `[webstir-hmr] Falling back to full reload for ${changedFile}. ` +
255
+ `Reason: ${reason ?? 'unknown'}.${fallbackReasons}`
256
+ );
257
+ if (typeof sessionStorage !== 'undefined') {
258
+ sessionStorage.setItem(
259
+ reloadMarkerKey,
260
+ ` Reason: ${reason ?? 'unknown'}.${fallbackReasons}`
261
+ );
262
+ }
263
+
264
+ setStatus('hmr-fallback', 'Hot update fallback – reloading…');
265
+ notifyFallback(reason, payload, details);
266
+ updateQueue.length = 0;
267
+ setTimeout(() => window.location.reload(), 0);
268
+ }
269
+
270
+ function setStatus(status, message) {
271
+ const setter = window.__webstirSetDevStatus;
272
+ if (typeof setter === 'function') {
273
+ try {
274
+ setter(status, message);
275
+ } catch (error) {
276
+ console.debug('[webstir-hmr] Status handler failed.', error);
277
+ }
278
+ }
279
+ }
280
+
281
+ function notifyFallback(reason, payload, details) {
282
+ const handler = window.__webstirOnHmrFallback;
283
+ if (typeof handler === 'function') {
284
+ try {
285
+ handler({ reason, payload, details });
286
+ } catch (error) {
287
+ console.debug('[webstir-hmr] Fallback hook threw.', error);
288
+ }
289
+ }
290
+ }
291
+
292
+ function readStats(candidate) {
293
+ if (!candidate || typeof candidate !== 'object') {
294
+ return null;
295
+ }
296
+
297
+ const hotUpdates = coerceInteger(candidate.hotUpdates);
298
+ const reloadFallbacks = coerceInteger(candidate.reloadFallbacks);
299
+
300
+ if (hotUpdates === null || reloadFallbacks === null) {
301
+ return null;
302
+ }
303
+
304
+ return {
305
+ hotUpdates,
306
+ reloadFallbacks
307
+ };
308
+ }
309
+
310
+ function coerceInteger(value) {
311
+ if (typeof value === 'number' && Number.isFinite(value)) {
312
+ return Math.trunc(value);
313
+ }
314
+
315
+ if (typeof value === 'string') {
316
+ const parsed = Number.parseInt(value, 10);
317
+ if (Number.isFinite(parsed)) {
318
+ return parsed;
319
+ }
320
+ }
321
+
322
+ return null;
323
+ }
324
+
325
+ function getOrCreateEventSource() {
326
+ if (window.__webstirEventSource instanceof EventSource) {
327
+ return window.__webstirEventSource;
328
+ }
329
+
330
+ const source = new EventSource('/sse');
331
+ window.__webstirEventSource = source;
332
+ return source;
333
+ }
334
+
335
+ function withCacheBuster(url, cacheBuster) {
336
+ if (typeof url !== 'string' || url.length === 0) {
337
+ return url;
338
+ }
339
+
340
+ try {
341
+ const parsed = new URL(url, window.location.origin);
342
+ parsed.searchParams.set('hmr', cacheBuster);
343
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
344
+ } catch {
345
+ const separator = url.includes('?') ? '&' : '?';
346
+ return `${url}${separator}hmr=${cacheBuster}`;
347
+ }
348
+ }
349
+
350
+ function normalizePath(url) {
351
+ if (typeof url !== 'string') {
352
+ return '';
353
+ }
354
+
355
+ try {
356
+ return new URL(url, window.location.origin).pathname;
357
+ } catch {
358
+ const index = url.indexOf('?');
359
+ return index === -1 ? url : url.slice(0, index);
360
+ }
361
+ }
362
+
363
+ function isValidAsset(asset) {
364
+ return Boolean(asset && typeof asset.url === 'string' && asset.url.length > 0);
365
+ }
366
+
367
+ function isPromise(value) {
368
+ return !!value && typeof value.then === 'function';
369
+ }
370
+ }
@@ -0,0 +1,163 @@
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
+ let currentStatus;
9
+ const STATUS_STORAGE_KEY = '__webstirDevStatus';
10
+ const STATUS_MAX_AGE_MS = 5000;
11
+
12
+ const indicator = document.createElement('div');
13
+ indicator.id = 'dev-server-indicator';
14
+ indicator.style.cssText = `
15
+ position: fixed;
16
+ bottom: 20px;
17
+ right: 20px;
18
+ color: white;
19
+ padding: 12px 16px;
20
+ border-radius: 20px;
21
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
22
+ font-size: 12px;
23
+ font-weight: 500;
24
+ z-index: 10000;
25
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
26
+ opacity: 0;
27
+ transition: opacity 0.3s ease;
28
+ `;
29
+
30
+ document.body.appendChild(indicator);
31
+
32
+ function updateIndicator(background, text, shouldReset = false) {
33
+ indicator.style.opacity = '1';
34
+ indicator.style.background = background;
35
+ indicator.textContent = text;
36
+
37
+ if (resetTimer) {
38
+ clearTimeout(resetTimer);
39
+ resetTimer = undefined;
40
+ }
41
+
42
+ if (shouldReset) {
43
+ resetTimer = setTimeout(setConnected, 1500);
44
+ }
45
+ }
46
+
47
+ function setConnected(message) {
48
+ updateIndicator('#4CAF50', message ?? '● Dev Server Connected');
49
+ }
50
+
51
+ function setDisconnected(message) {
52
+ updateIndicator('#f44336', message ?? 'Dev Server Disconnected');
53
+ }
54
+
55
+ function setBuilding(message) {
56
+ updateIndicator('#FF9800', message ?? '● Rebuilding…');
57
+ }
58
+
59
+ function setBuildSuccess(message) {
60
+ updateIndicator('#4CAF50', message ?? '● Rebuild Complete', true);
61
+ }
62
+
63
+ function setBuildFailure(message) {
64
+ updateIndicator('#f44336', message ?? '● Build Failed');
65
+ }
66
+
67
+ function setHmrFallback(message) {
68
+ updateIndicator('#FF5722', message ?? '● Reloading (HMR fallback)…');
69
+ }
70
+
71
+ const statusHandlers = {
72
+ connected: setConnected,
73
+ disconnected: setDisconnected,
74
+ building: setBuilding,
75
+ success: setBuildSuccess,
76
+ error: setBuildFailure,
77
+ 'hmr-fallback': setHmrFallback
78
+ };
79
+
80
+ function applyStatus(status, message) {
81
+ currentStatus = status;
82
+ const handler = statusHandlers[status];
83
+ if (typeof handler === 'function') {
84
+ handler(message);
85
+ }
86
+
87
+ if (status === 'connected' || status === 'disconnected') {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ sessionStorage.setItem(
93
+ STATUS_STORAGE_KEY,
94
+ JSON.stringify({ status, message, timestamp: Date.now() })
95
+ );
96
+ } catch {
97
+ // ignore
98
+ }
99
+ }
100
+
101
+ window.__webstirSetDevStatus = applyStatus;
102
+
103
+ try {
104
+ const raw = sessionStorage.getItem(STATUS_STORAGE_KEY);
105
+ if (raw) {
106
+ sessionStorage.removeItem(STATUS_STORAGE_KEY);
107
+ const saved = JSON.parse(raw);
108
+ if (saved && typeof saved === 'object') {
109
+ const age = Date.now() - (saved.timestamp ?? 0);
110
+ if (age >= 0 && age <= STATUS_MAX_AGE_MS && typeof saved.status === 'string') {
111
+ applyStatus(saved.status.trim(), typeof saved.message === 'string' ? saved.message : undefined);
112
+ }
113
+ }
114
+ }
115
+ } catch {
116
+ // ignore
117
+ }
118
+
119
+ let loggedConnected = false;
120
+ function markConnected() {
121
+ if (!loggedConnected) {
122
+ loggedConnected = true;
123
+ console.log('SSE connection established.');
124
+ }
125
+
126
+ if (indicator.style.opacity === '0' || currentStatus === 'disconnected') {
127
+ applyStatus('connected');
128
+ }
129
+ }
130
+
131
+ eventSource.onopen = () => {
132
+ markConnected();
133
+ };
134
+
135
+ if (eventSource.readyState === EventSource.OPEN) {
136
+ markConnected();
137
+ }
138
+
139
+ eventSource.onmessage = (event) => {
140
+ if (event.data === 'reload') {
141
+ applyStatus('success');
142
+ location.reload();
143
+ } else if (event.data === 'shutdown') {
144
+ isShuttingDown = true;
145
+ setDisconnected();
146
+ eventSource.close();
147
+ }
148
+ };
149
+
150
+ eventSource.addEventListener('status', (event) => {
151
+ applyStatus(String(event.data ?? '').trim());
152
+ });
153
+
154
+ eventSource.onerror = (error) => {
155
+ if (!isShuttingDown) {
156
+ console.error('SSE error:', error);
157
+ applyStatus('disconnected');
158
+ }
159
+ };
160
+
161
+ window.addEventListener('beforeunload', function () {
162
+ eventSource.close();
163
+ });
@@ -0,0 +1,183 @@
1
+ export type DrawerController = {
2
+ open: () => void;
3
+ close: () => void;
4
+ toggle: () => void;
5
+ isOpen: () => boolean;
6
+ syncOverlayOffset: () => void;
7
+ destroy: () => void;
8
+ };
9
+
10
+ type DrawerOverlayOptions = {
11
+ headerSelector?: string;
12
+ target?: HTMLElement;
13
+ varName?: string;
14
+ };
15
+
16
+ type DrawerOptions = {
17
+ root: HTMLElement;
18
+ openAttribute?: string | null;
19
+ openClass?: string | null;
20
+ bodyClass?: string | null;
21
+ overlay?: DrawerOverlayOptions;
22
+ isActive?: () => boolean;
23
+ closeOnEscape?: boolean;
24
+ closeOnOutside?: boolean;
25
+ closeSelectors?: string[];
26
+ trapFocus?: boolean;
27
+ onOpen?: () => void;
28
+ onClose?: () => void;
29
+ };
30
+
31
+ function getFocusable(root: HTMLElement): HTMLElement[] {
32
+ const selectors = [
33
+ 'button:not([disabled])',
34
+ 'a[href]',
35
+ 'input:not([disabled])',
36
+ 'textarea:not([disabled])',
37
+ '[tabindex]:not([tabindex="-1"])'
38
+ ].join(',');
39
+ return Array.from(root.querySelectorAll<HTMLElement>(selectors))
40
+ .filter((el) => !el.hasAttribute('hidden') && el.offsetParent !== null);
41
+ }
42
+
43
+ export function createDrawer(options: DrawerOptions): DrawerController {
44
+ const openAttribute = options.openAttribute === undefined ? 'data-open' : options.openAttribute;
45
+ const openClass = options.openClass ?? null;
46
+ const bodyClass = options.bodyClass ?? null;
47
+ const overlayVar = options.overlay?.varName ?? '--ws-drawer-top';
48
+ const overlayTarget = options.overlay?.target ?? options.root;
49
+ const headerSelector = options.overlay?.headerSelector ?? '.app-header';
50
+ const isActive = options.isActive ?? (() => true);
51
+ const closeSelectors = options.closeSelectors?.filter(Boolean).join(',') ?? '';
52
+
53
+ let open = false;
54
+
55
+ const syncOverlayOffset = () => {
56
+ if (!options.overlay) {
57
+ return;
58
+ }
59
+
60
+ const header = document.querySelector<HTMLElement>(headerSelector);
61
+ if (!header) {
62
+ return;
63
+ }
64
+
65
+ const rect = header.getBoundingClientRect();
66
+ const top = Math.max(0, Math.round(rect.bottom));
67
+ overlayTarget.style.setProperty(overlayVar, `${top}px`);
68
+ };
69
+
70
+ const setOpen = (next: boolean) => {
71
+ if (open === next) {
72
+ return;
73
+ }
74
+
75
+ open = next;
76
+
77
+ if (openAttribute) {
78
+ if (open) {
79
+ options.root.setAttribute(openAttribute, 'true');
80
+ } else {
81
+ options.root.removeAttribute(openAttribute);
82
+ }
83
+ }
84
+
85
+ if (openClass) {
86
+ options.root.classList.toggle(openClass, open);
87
+ }
88
+
89
+ if (bodyClass) {
90
+ document.body.classList.toggle(bodyClass, open);
91
+ }
92
+
93
+ if (open) {
94
+ options.onOpen?.();
95
+ syncOverlayOffset();
96
+ } else {
97
+ options.onClose?.();
98
+ }
99
+ };
100
+
101
+ const handleKeydown = (event: KeyboardEvent) => {
102
+ if (!open || !isActive()) {
103
+ return;
104
+ }
105
+
106
+ if (options.closeOnEscape && event.key === 'Escape') {
107
+ event.preventDefault();
108
+ setOpen(false);
109
+ return;
110
+ }
111
+
112
+ if (options.trapFocus && event.key === 'Tab') {
113
+ const active = document.activeElement;
114
+ if (!(active instanceof HTMLElement) || !options.root.contains(active)) {
115
+ return;
116
+ }
117
+
118
+ const focusable = getFocusable(options.root);
119
+ if (focusable.length === 0) {
120
+ return;
121
+ }
122
+
123
+ const currentIndex = focusable.indexOf(active);
124
+ const nextIndex = event.shiftKey
125
+ ? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1)
126
+ : (currentIndex === -1 || currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1);
127
+
128
+ event.preventDefault();
129
+ focusable[nextIndex]?.focus();
130
+ }
131
+ };
132
+
133
+ const handleClick = (event: MouseEvent) => {
134
+ if (!open || !isActive()) {
135
+ return;
136
+ }
137
+
138
+ const target = event.target;
139
+ if (!(target instanceof Element)) {
140
+ return;
141
+ }
142
+
143
+ if (options.closeOnOutside && !options.root.contains(target)) {
144
+ setOpen(false);
145
+ return;
146
+ }
147
+
148
+ if (closeSelectors && target.closest(closeSelectors)) {
149
+ setOpen(false);
150
+ }
151
+ };
152
+
153
+ document.addEventListener('keydown', handleKeydown);
154
+ document.addEventListener('click', handleClick, true);
155
+
156
+ return {
157
+ open: () => {
158
+ if (!isActive()) {
159
+ return;
160
+ }
161
+ setOpen(true);
162
+ },
163
+ close: () => {
164
+ setOpen(false);
165
+ },
166
+ toggle: () => {
167
+ if (open) {
168
+ setOpen(false);
169
+ return;
170
+ }
171
+ if (!isActive()) {
172
+ return;
173
+ }
174
+ setOpen(true);
175
+ },
176
+ isOpen: () => open,
177
+ syncOverlayOffset,
178
+ destroy: () => {
179
+ document.removeEventListener('keydown', handleKeydown);
180
+ document.removeEventListener('click', handleClick, true);
181
+ }
182
+ };
183
+ }