create-nativecore 0.1.0 → 0.2.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 (175) hide show
  1. package/README.md +10 -18
  2. package/bin/index.mjs +407 -489
  3. package/package.json +4 -3
  4. package/template/.env.example +28 -0
  5. package/template/.htmlhintrc +14 -0
  6. package/template/api/data/dashboard.json +11 -0
  7. package/template/api/data/users.json +18 -0
  8. package/template/api/mockApi.js +161 -0
  9. package/template/assets/icon.svg +13 -0
  10. package/template/assets/logo.svg +25 -0
  11. package/template/eslint.config.js +94 -0
  12. package/template/index.html +137 -0
  13. package/template/manifest.json +19 -0
  14. package/template/public/.well-known/security.txt +9 -0
  15. package/template/public/_headers +24 -0
  16. package/template/public/_redirects +14 -0
  17. package/template/public/assets/icon.svg +13 -0
  18. package/template/public/assets/logo.svg +25 -0
  19. package/template/public/manifest.json +19 -0
  20. package/template/public/robots.txt +13 -0
  21. package/template/public/sitemap.xml +27 -0
  22. package/template/scripts/build-for-bots.mjs +121 -0
  23. package/template/scripts/convert-to-ts.mjs +106 -0
  24. package/template/scripts/fix-encoding.mjs +38 -0
  25. package/template/scripts/fix-svg-paths.mjs +32 -0
  26. package/template/scripts/generate-cf-router.mjs +52 -0
  27. package/template/scripts/inject-dev-tools.mjs +41 -0
  28. package/template/scripts/inject-version.mjs +65 -0
  29. package/template/scripts/make-component.mjs +445 -0
  30. package/template/scripts/make-component.mjs.backup +432 -0
  31. package/template/scripts/make-controller.mjs +119 -0
  32. package/template/scripts/make-core-component.mjs +303 -0
  33. package/template/scripts/make-view.mjs +346 -0
  34. package/template/scripts/minify.mjs +71 -0
  35. package/template/scripts/prepare-static-assets.mjs +141 -0
  36. package/template/scripts/prompt-bot-build.mjs +223 -0
  37. package/template/scripts/remove-component.mjs +170 -0
  38. package/template/scripts/remove-core-component.mjs +156 -0
  39. package/template/scripts/remove-dev.mjs +13 -0
  40. package/template/scripts/remove-view.mjs +200 -0
  41. package/template/scripts/strip-dev-blocks.mjs +30 -0
  42. package/template/scripts/watch-compile.mjs +69 -0
  43. package/template/server.js +1066 -0
  44. package/template/src/app.ts +115 -0
  45. package/template/src/components/appRegistry.ts +8 -0
  46. package/template/src/components/core/app-footer.ts +27 -0
  47. package/template/src/components/core/app-header.ts +175 -0
  48. package/template/src/components/core/app-sidebar.ts +238 -0
  49. package/template/src/components/core/loading-spinner.ts +25 -0
  50. package/template/src/components/core/nc-a.ts +313 -0
  51. package/template/src/components/core/nc-accordion.ts +186 -0
  52. package/template/src/components/core/nc-alert.ts +153 -0
  53. package/template/src/components/core/nc-animation.ts +1150 -0
  54. package/template/src/components/core/nc-autocomplete.ts +271 -0
  55. package/template/src/components/core/nc-avatar-group.ts +113 -0
  56. package/template/src/components/core/nc-avatar.ts +148 -0
  57. package/template/src/components/core/nc-badge.ts +86 -0
  58. package/template/src/components/core/nc-bottom-nav.ts +214 -0
  59. package/template/src/components/core/nc-breadcrumb.ts +96 -0
  60. package/template/src/components/core/nc-button.ts +307 -0
  61. package/template/src/components/core/nc-card.ts +160 -0
  62. package/template/src/components/core/nc-checkbox.ts +282 -0
  63. package/template/src/components/core/nc-chip.ts +115 -0
  64. package/template/src/components/core/nc-code.ts +314 -0
  65. package/template/src/components/core/nc-collapsible.ts +154 -0
  66. package/template/src/components/core/nc-color-picker.ts +268 -0
  67. package/template/src/components/core/nc-copy-button.ts +119 -0
  68. package/template/src/components/core/nc-date-picker.ts +443 -0
  69. package/template/src/components/core/nc-div.ts +280 -0
  70. package/template/src/components/core/nc-divider.ts +81 -0
  71. package/template/src/components/core/nc-drawer.ts +230 -0
  72. package/template/src/components/core/nc-dropdown.ts +178 -0
  73. package/template/src/components/core/nc-empty-state.ts +134 -0
  74. package/template/src/components/core/nc-file-upload.ts +354 -0
  75. package/template/src/components/core/nc-form.ts +312 -0
  76. package/template/src/components/core/nc-image.ts +184 -0
  77. package/template/src/components/core/nc-input.ts +383 -0
  78. package/template/src/components/core/nc-kbd.ts +48 -0
  79. package/template/src/components/core/nc-menu-item.ts +193 -0
  80. package/template/src/components/core/nc-menu.ts +376 -0
  81. package/template/src/components/core/nc-modal.ts +238 -0
  82. package/template/src/components/core/nc-nav-item.ts +151 -0
  83. package/template/src/components/core/nc-number-input.ts +350 -0
  84. package/template/src/components/core/nc-otp-input.ts +235 -0
  85. package/template/src/components/core/nc-pagination.ts +178 -0
  86. package/template/src/components/core/nc-popover.ts +260 -0
  87. package/template/src/components/core/nc-progress-circular.ts +119 -0
  88. package/template/src/components/core/nc-progress.ts +134 -0
  89. package/template/src/components/core/nc-radio.ts +235 -0
  90. package/template/src/components/core/nc-rating.ts +266 -0
  91. package/template/src/components/core/nc-rich-text.ts +283 -0
  92. package/template/src/components/core/nc-scroll-top.ts +116 -0
  93. package/template/src/components/core/nc-select.ts +452 -0
  94. package/template/src/components/core/nc-skeleton.ts +107 -0
  95. package/template/src/components/core/nc-slider.ts +285 -0
  96. package/template/src/components/core/nc-snackbar.ts +230 -0
  97. package/template/src/components/core/nc-splash.ts +343 -0
  98. package/template/src/components/core/nc-stepper.ts +247 -0
  99. package/template/src/components/core/nc-switch.ts +281 -0
  100. package/template/src/components/core/nc-tab-item.ts +138 -0
  101. package/template/src/components/core/nc-table.ts +279 -0
  102. package/template/src/components/core/nc-tabs.ts +554 -0
  103. package/template/src/components/core/nc-tag-input.ts +279 -0
  104. package/template/src/components/core/nc-textarea.ts +216 -0
  105. package/template/src/components/core/nc-time-picker.ts +438 -0
  106. package/template/src/components/core/nc-timeline.ts +186 -0
  107. package/template/src/components/core/nc-tooltip.ts +143 -0
  108. package/template/src/components/frameworkRegistry.ts +68 -0
  109. package/template/src/components/preloadRegistry.ts +28 -0
  110. package/template/src/components/registry.ts +8 -0
  111. package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
  112. package/template/src/constants/apiEndpoints.ts +27 -0
  113. package/template/src/constants/errorMessages.ts +23 -0
  114. package/template/src/constants/index.ts +8 -0
  115. package/template/src/constants/routePaths.ts +15 -0
  116. package/template/src/constants/storageKeys.ts +18 -0
  117. package/template/src/controllers/dashboard.controller.ts +200 -0
  118. package/template/src/controllers/home.controller.ts +21 -0
  119. package/template/src/controllers/index.ts +11 -0
  120. package/template/src/controllers/login.controller.ts +131 -0
  121. package/template/src/core/component.ts +354 -0
  122. package/template/src/core/errorHandler.ts +85 -0
  123. package/template/src/core/gpu-animation.ts +604 -0
  124. package/template/src/core/http.ts +173 -0
  125. package/template/src/core/lazyComponents.ts +90 -0
  126. package/template/src/core/router.ts +642 -0
  127. package/template/src/core/signals.ts +146 -0
  128. package/template/src/core/state.ts +248 -0
  129. package/template/src/dev/component-editor.ts +1363 -0
  130. package/template/src/dev/component-overlay.ts +278 -0
  131. package/template/src/dev/context-menu.ts +223 -0
  132. package/template/src/dev/denc-tools.ts +250 -0
  133. package/template/src/dev/hmr.ts +189 -0
  134. package/template/src/dev/nfbs.code-workspace +27 -0
  135. package/template/src/dev/outline-panel.ts +1247 -0
  136. package/template/src/middleware/auth.middleware.ts +23 -0
  137. package/template/src/routes/routes.ts +38 -0
  138. package/template/src/services/api.service.ts +394 -0
  139. package/template/src/services/auth.service.ts +176 -0
  140. package/template/src/services/index.ts +8 -0
  141. package/template/src/services/logger.service.ts +74 -0
  142. package/template/src/services/storage.service.ts +88 -0
  143. package/template/src/stores/appStore.ts +57 -0
  144. package/template/src/stores/uiStore.ts +36 -0
  145. package/template/src/styles/core-variables.css +219 -0
  146. package/template/src/styles/core.css +710 -0
  147. package/template/src/styles/main.css +3164 -0
  148. package/template/src/styles/variables.css +152 -0
  149. package/template/src/types/global.d.ts +47 -0
  150. package/template/src/utils/cacheBuster.ts +20 -0
  151. package/template/src/utils/dom.ts +149 -0
  152. package/template/src/utils/events.ts +203 -0
  153. package/template/src/utils/form.ts +176 -0
  154. package/template/src/utils/formatters.ts +169 -0
  155. package/template/src/utils/helpers.ts +195 -0
  156. package/template/src/utils/markdown.ts +307 -0
  157. package/template/src/utils/sidebar.ts +96 -0
  158. package/template/src/utils/smoothScroll.ts +85 -0
  159. package/template/src/utils/templates.ts +23 -0
  160. package/template/src/utils/validation.ts +73 -0
  161. package/template/src/views/protected/dashboard.html +293 -0
  162. package/template/src/views/public/home.html +150 -0
  163. package/template/src/views/public/login.html +102 -0
  164. package/template/tests/unit/component.test.ts +87 -0
  165. package/template/tests/unit/computed.test.ts +79 -0
  166. package/template/tests/unit/form.test.ts +68 -0
  167. package/template/tests/unit/formatters.test.ts +49 -0
  168. package/template/tests/unit/lazy-components.test.ts +59 -0
  169. package/template/tests/unit/markdown.test.ts +62 -0
  170. package/template/tests/unit/router.test.ts +112 -0
  171. package/template/tests/unit/signals.test.ts +54 -0
  172. package/template/tests/unit/validation.test.ts +50 -0
  173. package/template/tsconfig.build.json +21 -0
  174. package/template/tsconfig.json +51 -0
  175. package/template/vitest.config.ts +36 -0
@@ -0,0 +1,642 @@
1
+ /**
2
+ * SPA Router - Handles navigation without page reloads
3
+ * Uses History API to manage URLs dynamically
4
+ */
5
+
6
+ import { bustCache } from '@utils/cacheBuster.js';
7
+
8
+ // Types
9
+ export interface CachePolicy {
10
+ /** Seconds before the cached HTML is considered stale. Default: 0 (no cache). */
11
+ ttl: number;
12
+ /**
13
+ * When true, serve stale HTML instantly while refreshing in the background.
14
+ * When false (default), block navigation until fresh HTML is fetched.
15
+ */
16
+ revalidate?: boolean;
17
+ }
18
+
19
+ export interface RouteConfig {
20
+ htmlFile: string;
21
+ controller?: ControllerFunction | null;
22
+ cachePolicy?: CachePolicy;
23
+ layout?: string;
24
+ }
25
+
26
+ export interface RouteMatch {
27
+ path: string;
28
+ params: Record<string, string>;
29
+ config: RouteConfig;
30
+ }
31
+
32
+ export type ControllerFunction = (params: Record<string, string>, state?: any) => Promise<(() => void) | void> | (() => void) | void;
33
+ export type MiddlewareFunction = (route: RouteMatch, state?: any) => Promise<boolean> | boolean;
34
+
35
+ interface CacheEntry {
36
+ html: string;
37
+ cachedAt: number;
38
+ ttl: number;
39
+ }
40
+
41
+ export class Router {
42
+ private routes: Record<string, RouteConfig> = {};
43
+ private currentRoute: RouteMatch | null = null;
44
+ private middlewares: MiddlewareFunction[] = [];
45
+ private htmlCache: Map<string, CacheEntry> = new Map();
46
+ private pageScripts: Record<string, { cleanup?: () => void }> = {};
47
+ private navigationController: AbortController | null = null;
48
+ private isNavigating = false;
49
+ private renderedLayoutPath: string | null = null;
50
+
51
+ constructor() {
52
+ if ('scrollRestoration' in window.history) {
53
+ window.history.scrollRestoration = 'manual';
54
+ }
55
+
56
+ // Listen for browser back/forward buttons
57
+ window.addEventListener('popstate', (e: PopStateEvent) => {
58
+ this.handleRoute(window.location.pathname, e.state);
59
+ });
60
+
61
+ // Intercept all link clicks for SPA navigation
62
+ document.addEventListener('click', (e: MouseEvent) => {
63
+ const target = e.target as HTMLElement;
64
+ const link = target.closest('a') as HTMLAnchorElement;
65
+
66
+ // Check if it's a link
67
+ if (link && link.tagName === 'A') {
68
+ const href = link.getAttribute('href');
69
+
70
+ // Skip if no href
71
+ if (!href) return;
72
+
73
+ // Skip external links (http://, https://, mailto:, tel:, etc.)
74
+ if (href.startsWith('http://') || href.startsWith('https://') ||
75
+ href.startsWith('mailto:') || href.startsWith('tel:') ||
76
+ href.startsWith('#')) {
77
+ return;
78
+ }
79
+
80
+ // Skip if target="_blank" or data-external attribute
81
+ if (link.target === '_blank' || link.hasAttribute('data-external')) {
82
+ return;
83
+ }
84
+
85
+ // Handle as SPA navigation
86
+ e.preventDefault();
87
+ this.navigate(href);
88
+ }
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Register a route
94
+ */
95
+ register(
96
+ path: string,
97
+ htmlFile: string,
98
+ controller: ControllerFunction | null = null,
99
+ options: Partial<RouteConfig> = {}
100
+ ): this {
101
+ this.routes[path] = { htmlFile, controller, ...options };
102
+ return this;
103
+ }
104
+
105
+ /**
106
+ * Set a cache policy for the last registered route.
107
+ *
108
+ * @example
109
+ * router
110
+ * .register('/about', 'views/about.html')
111
+ * .cache({ ttl: 300 }) // cache 5 minutes, block on stale
112
+ *
113
+ * @example
114
+ * .register('/home', 'views/home.html', homeController)
115
+ * .cache({ ttl: 60, revalidate: true }) // serve stale instantly, refresh in bg
116
+ */
117
+ cache(policy: CachePolicy): this {
118
+ const paths = Object.keys(this.routes);
119
+ const last = paths[paths.length - 1];
120
+ if (last) this.routes[last].cachePolicy = policy;
121
+ return this;
122
+ }
123
+
124
+ /**
125
+ * Manually bust the HTML cache for a specific path (or all paths).
126
+ */
127
+ bustCache(path?: string): void {
128
+ if (path) {
129
+ const config = this.routes[path];
130
+ if (config) {
131
+ this.htmlCache.delete(config.htmlFile);
132
+
133
+ if (config.layout) {
134
+ const layoutConfig = this.routes[config.layout];
135
+ if (layoutConfig) {
136
+ this.htmlCache.delete(layoutConfig.htmlFile);
137
+ }
138
+ }
139
+ }
140
+ } else {
141
+ this.htmlCache.clear();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Prefetch a route's HTML (and layout HTML when applicable) without navigating.
147
+ */
148
+ async prefetch(path: string): Promise<void> {
149
+ const route = this.matchRoute(path);
150
+ if (!route) return;
151
+
152
+ const requests: Array<Promise<string>> = [
153
+ this.fetchHTML(route.config.htmlFile, route.config.cachePolicy, true)
154
+ ];
155
+ const layoutRoute = this.getLayoutRoute(route);
156
+
157
+ if (layoutRoute) {
158
+ requests.push(this.fetchHTML(layoutRoute.config.htmlFile, layoutRoute.config.cachePolicy, true));
159
+ }
160
+
161
+ await Promise.allSettled(requests);
162
+ }
163
+
164
+ /**
165
+ * Add middleware
166
+ */
167
+ use(middleware: MiddlewareFunction): this {
168
+ this.middlewares.push(middleware);
169
+ return this;
170
+ }
171
+
172
+ /**
173
+ * Navigate to a new route
174
+ */
175
+ navigate(path: string, state: any = {}): void {
176
+ const browserPath = this.normalizeBrowserPath(path);
177
+
178
+ // Abort any previous navigation
179
+ if (this.navigationController) {
180
+ this.navigationController.abort();
181
+ }
182
+
183
+ // Create new abort controller for this navigation
184
+ this.navigationController = new AbortController();
185
+
186
+ window.history.pushState(state, '', browserPath);
187
+ this.handleRoute(browserPath, state);
188
+ }
189
+
190
+ /**
191
+ * Replace current route
192
+ */
193
+ replace(path: string, state: any = {}): void {
194
+ const browserPath = this.normalizeBrowserPath(path);
195
+ window.history.replaceState(state, '', browserPath);
196
+ this.handleRoute(browserPath, state);
197
+ }
198
+
199
+ /**
200
+ * Force reload current route (for HMR)
201
+ */
202
+ reload(): void {
203
+ // Reset navigation state
204
+ this.isNavigating = false;
205
+ if (this.navigationController) {
206
+ this.navigationController.abort();
207
+ }
208
+ // Force reload current path
209
+ const currentPath = window.location.pathname;
210
+ this.handleRoute(currentPath, {});
211
+ }
212
+
213
+ /**
214
+ * Go back
215
+ */
216
+ back(): void {
217
+ window.history.back();
218
+ }
219
+
220
+ /**
221
+ * Handle route
222
+ */
223
+ private async handleRoute(path: string, state: any = {}): Promise<void> {
224
+ // Prevent concurrent navigations
225
+ if (this.isNavigating && this.navigationController?.signal.aborted === false) {
226
+ return;
227
+ }
228
+
229
+ this.isNavigating = true;
230
+
231
+ const route = this.matchRoute(path);
232
+
233
+ if (!route) {
234
+ this.handle404(path);
235
+ this.isNavigating = false;
236
+ return;
237
+ }
238
+
239
+ // Check if this navigation was aborted
240
+ if (this.navigationController?.signal.aborted) {
241
+ this.isNavigating = false;
242
+ return;
243
+ }
244
+
245
+ // Run middlewares
246
+ for (const middleware of this.middlewares) {
247
+ const result = await middleware(route, state);
248
+ if (result === false) {
249
+ this.isNavigating = false;
250
+ return;
251
+ }
252
+ }
253
+
254
+ // Check if aborted during middleware
255
+ if (this.navigationController?.signal.aborted) {
256
+ this.isNavigating = false;
257
+ return;
258
+ }
259
+
260
+ const previousRoute = this.currentRoute;
261
+ this.currentRoute = route;
262
+ await this.loadPage(route, state, previousRoute);
263
+ this.isNavigating = false;
264
+ }
265
+
266
+ /**
267
+ * Load page
268
+ */
269
+ private async loadPage(route: RouteMatch, state: any = {}, previousRoute: RouteMatch | null = null): Promise<void> {
270
+ const mainContent = document.getElementById('main-content');
271
+ const progressBar = document.getElementById('page-progress');
272
+
273
+ if (!mainContent) {
274
+ console.error('main-content element not found');
275
+ return;
276
+ }
277
+
278
+ try {
279
+ const isPrerenderedInitialRoute =
280
+ previousRoute === null &&
281
+ mainContent.getAttribute('data-prerendered-route') === route.path &&
282
+ !route.config.layout;
283
+
284
+ if (progressBar) {
285
+ progressBar.classList.add('loading');
286
+ }
287
+
288
+ this.resetScrollPosition(mainContent);
289
+
290
+ if (previousRoute?.path && this.pageScripts[previousRoute.path]?.cleanup) {
291
+ this.pageScripts[previousRoute.path].cleanup!();
292
+ }
293
+
294
+ if (!isPrerenderedInitialRoute) {
295
+ mainContent.classList.add('page-transition-exit');
296
+ await new Promise(resolve => setTimeout(resolve, 50));
297
+
298
+ const contentTarget = await this.resolveContentTarget(mainContent, route);
299
+ const html = await this.fetchHTML(route.config.htmlFile, route.config.cachePolicy);
300
+
301
+ contentTarget.innerHTML = html;
302
+ mainContent.classList.remove('page-transition-exit');
303
+ mainContent.classList.add('page-transition-enter');
304
+ }
305
+
306
+ if (route.config.controller) {
307
+ const cleanup = await route.config.controller(route.params, state);
308
+ this.pageScripts[route.path] = {
309
+ cleanup: typeof cleanup === 'function' ? cleanup : undefined
310
+ };
311
+ }
312
+
313
+ if (isPrerenderedInitialRoute) {
314
+ mainContent.removeAttribute('data-prerendered-route');
315
+ }
316
+
317
+ window.dispatchEvent(new CustomEvent('pageloaded', { detail: route }));
318
+
319
+ // Scroll to top on page navigation
320
+ this.resetScrollPosition(mainContent);
321
+
322
+ if (progressBar) {
323
+ setTimeout(() => progressBar.classList.remove('loading'), 200);
324
+ }
325
+
326
+ if (!isPrerenderedInitialRoute) {
327
+ setTimeout(() => {
328
+ mainContent.classList.remove('page-transition-enter');
329
+ }, 150);
330
+ }
331
+
332
+ } catch (error) {
333
+ console.error('Error loading page:', error);
334
+ if (progressBar) {
335
+ progressBar.classList.remove('loading');
336
+ }
337
+
338
+ // Show 404 page when file doesn't exist
339
+ this.handle404(route.path);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Fetch HTML with TTL-aware caching.
345
+ */
346
+ private async fetchHTML(file: string, policy?: CachePolicy, allowPrefetchCache = false): Promise<string> {
347
+ const entry = this.htmlCache.get(file);
348
+ const now = Date.now();
349
+ const ttlMs = this.resolveCacheTtl(policy, entry) * 1000;
350
+ const isFresh = entry && ttlMs > 0 && now - entry.cachedAt < ttlMs;
351
+ const isStale = entry && ttlMs > 0 && !isFresh;
352
+
353
+ // Serve stale immediately and kick off a background refresh
354
+ if (isStale && policy?.revalidate) {
355
+ this.refreshInBackground(file, policy.ttl);
356
+ return entry.html;
357
+ }
358
+
359
+ // Serve fresh from cache
360
+ if (isFresh) return entry.html;
361
+
362
+ // Fetch from network
363
+ const response = await fetch(bustCache(file), { cache: 'no-store' });
364
+ if (!response.ok) throw new Error(`Failed to load ${file}`);
365
+ const html = await response.text();
366
+
367
+ if (ttlMs > 0 || allowPrefetchCache) {
368
+ this.htmlCache.set(file, {
369
+ html,
370
+ cachedAt: now,
371
+ ttl: policy?.ttl ?? entry?.ttl ?? 30
372
+ });
373
+ }
374
+
375
+ return html;
376
+ }
377
+
378
+ private async refreshInBackground(file: string, ttl: number): Promise<void> {
379
+ try {
380
+ const response = await fetch(bustCache(file), { cache: 'no-store' });
381
+ if (!response.ok) return;
382
+ const html = await response.text();
383
+ this.htmlCache.set(file, { html, cachedAt: Date.now(), ttl });
384
+ } catch {
385
+ // silently ignore background refresh failures
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Match route
391
+ */
392
+ private matchRoute(path: string): RouteMatch | null {
393
+ const normalizedPath = this.normalizeRoutePath(path);
394
+
395
+ // Exact match
396
+ if (this.routes[normalizedPath]) {
397
+ return { path: normalizedPath, params: {}, config: this.routes[normalizedPath] };
398
+ }
399
+
400
+ // Dynamic match
401
+ for (const [routePath, config] of Object.entries(this.routes)) {
402
+ const params = this.extractParams(routePath, normalizedPath);
403
+ if (params) {
404
+ return { path: routePath, params, config };
405
+ }
406
+ }
407
+
408
+ return null;
409
+ }
410
+
411
+ /**
412
+ * Extract params
413
+ */
414
+ private extractParams(routePath: string, actualPath: string): Record<string, string> | null {
415
+ const routeParts = this.splitPath(routePath);
416
+ const actualParts = this.splitPath(actualPath);
417
+ const params: Record<string, string> = {};
418
+
419
+ let routeIndex = 0;
420
+ let actualIndex = 0;
421
+
422
+ while (routeIndex < routeParts.length) {
423
+ const routePart = routeParts[routeIndex];
424
+ const actualPart = actualParts[actualIndex];
425
+
426
+ if (routePart === '*') {
427
+ params.wildcard = actualParts.slice(actualIndex).join('/');
428
+ actualIndex = actualParts.length;
429
+ routeIndex++;
430
+ continue;
431
+ }
432
+
433
+ if (routePart.startsWith(':') && routePart.endsWith('?')) {
434
+ const paramName = routePart.slice(1, -1);
435
+ if (actualPart !== undefined) {
436
+ params[paramName] = actualPart;
437
+ actualIndex++;
438
+ }
439
+ routeIndex++;
440
+ continue;
441
+ }
442
+
443
+ if (actualPart === undefined) {
444
+ return null;
445
+ }
446
+
447
+ if (routePart.startsWith(':')) {
448
+ params[routePart.slice(1)] = actualPart;
449
+ routeIndex++;
450
+ actualIndex++;
451
+ continue;
452
+ }
453
+
454
+ if (routePart !== actualPart) {
455
+ return null;
456
+ }
457
+
458
+ routeIndex++;
459
+ actualIndex++;
460
+ }
461
+
462
+ return actualIndex === actualParts.length ? params : null;
463
+ }
464
+
465
+ /**
466
+ * Handle 404
467
+ */
468
+ private handle404(path: string): void {
469
+ const mainContent = document.getElementById('main-content');
470
+ if (mainContent) {
471
+ this.resetScrollPosition(mainContent);
472
+ mainContent.innerHTML = `
473
+ <div style="
474
+ display: flex;
475
+ flex-direction: column;
476
+ align-items: center;
477
+ justify-content: center;
478
+ min-height: 60vh;
479
+ text-align: center;
480
+ padding: var(--spacing-xl);
481
+ ">
482
+ <div style="
483
+ font-size: 8rem;
484
+ font-weight: 700;
485
+ color: var(--primary);
486
+ line-height: 1;
487
+ margin-bottom: var(--spacing-md);
488
+ ">404</div>
489
+
490
+ <h1 style="
491
+ font-size: 2rem;
492
+ font-weight: 600;
493
+ color: var(--text-primary);
494
+ margin-bottom: var(--spacing-sm);
495
+ ">Page Not Found</h1>
496
+
497
+ <p style="
498
+ font-size: 1.1rem;
499
+ color: var(--text-secondary);
500
+ max-width: 500px;
501
+ margin-bottom: var(--spacing-lg);
502
+ ">
503
+ The page <code style="
504
+ background: var(--background-secondary);
505
+ padding: 0.2rem 0.5rem;
506
+ border-radius: var(--radius-sm);
507
+ color: var(--primary);
508
+ ">${path}</code> could not be found.
509
+ </p>
510
+
511
+ <button onclick="window.history.back()" style="
512
+ display: inline-flex;
513
+ align-items: center;
514
+ gap: 0.5rem;
515
+ padding: var(--spacing-sm) var(--spacing-lg);
516
+ background: var(--primary);
517
+ color: white;
518
+ border: none;
519
+ border-radius: var(--radius-md);
520
+ font-weight: 500;
521
+ font-size: 1rem;
522
+ cursor: pointer;
523
+ transition: all 0.2s ease;
524
+ " onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='var(--shadow-md)'"
525
+ onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'">
526
+ <span>←</span> Go Back
527
+ </button>
528
+ </div>
529
+ `;
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Start router
535
+ */
536
+ start(): void {
537
+ const browserPath = this.normalizeBrowserPath(window.location.pathname + window.location.search + window.location.hash);
538
+
539
+ if (browserPath !== window.location.pathname + window.location.search + window.location.hash) {
540
+ window.history.replaceState(window.history.state, '', browserPath);
541
+ }
542
+
543
+ this.handleRoute(browserPath);
544
+ }
545
+
546
+ /**
547
+ * Get current route
548
+ */
549
+ getCurrentRoute(): RouteMatch | null {
550
+ return this.currentRoute;
551
+ }
552
+
553
+ private async resolveContentTarget(mainContent: HTMLElement, route: RouteMatch): Promise<HTMLElement> {
554
+ const layoutRoute = this.getLayoutRoute(route);
555
+
556
+ if (!layoutRoute) {
557
+ this.renderedLayoutPath = null;
558
+ return mainContent;
559
+ }
560
+
561
+ const needsLayoutRender = this.renderedLayoutPath !== layoutRoute.path ||
562
+ !mainContent.querySelector('#route-outlet');
563
+
564
+ if (needsLayoutRender) {
565
+ const layoutHtml = await this.fetchHTML(layoutRoute.config.htmlFile, layoutRoute.config.cachePolicy);
566
+ mainContent.innerHTML = layoutHtml;
567
+ this.renderedLayoutPath = layoutRoute.path;
568
+ }
569
+
570
+ const outlet = mainContent.querySelector<HTMLElement>('#route-outlet');
571
+ if (!outlet) {
572
+ throw new Error(`Layout route "${layoutRoute.path}" is missing a #route-outlet element`);
573
+ }
574
+
575
+ return outlet;
576
+ }
577
+
578
+ private getLayoutRoute(route: RouteMatch): RouteMatch | null {
579
+ const layoutPath = route.config.layout;
580
+ if (!layoutPath) return null;
581
+
582
+ const layoutConfig = this.routes[layoutPath];
583
+ if (!layoutConfig) {
584
+ throw new Error(`Layout route "${layoutPath}" is not registered`);
585
+ }
586
+
587
+ return {
588
+ path: layoutPath,
589
+ params: {},
590
+ config: layoutConfig
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Split a route path into normalized segments while ignoring leading/trailing slashes.
596
+ */
597
+ private splitPath(path: string): string[] {
598
+ const trimmed = path.replace(/^\/+|\/+$/g, '');
599
+ return trimmed ? trimmed.split('/') : [];
600
+ }
601
+
602
+ private normalizeRoutePath(path: string): string {
603
+ const cleanedPath = path.replace(/[?#].*$/, '').replace(/\/+$/, '');
604
+ return cleanedPath || '/';
605
+ }
606
+
607
+ private normalizeBrowserPath(path: string): string {
608
+ const [pathnameWithQuery, hash = ''] = path.split('#');
609
+ const [pathname = '/', query = ''] = pathnameWithQuery.split('?');
610
+
611
+ if (!pathname || pathname === '/' || pathname.endsWith('/') || /\.[a-z0-9]+$/i.test(pathname)) {
612
+ return path;
613
+ }
614
+
615
+ const normalizedPath = `${pathname}/`;
616
+ const querySuffix = query ? `?${query}` : '';
617
+ const hashSuffix = hash ? `#${hash}` : '';
618
+ return `${normalizedPath}${querySuffix}${hashSuffix}`;
619
+ }
620
+
621
+ private resolveCacheTtl(policy?: CachePolicy, entry?: CacheEntry): number {
622
+ return policy?.ttl ?? entry?.ttl ?? 0;
623
+ }
624
+
625
+ private resetScrollPosition(mainContent?: HTMLElement | null): void {
626
+ window.scrollTo(0, 0);
627
+
628
+ if (mainContent) {
629
+ mainContent.scrollTop = 0;
630
+ mainContent.scrollLeft = 0;
631
+
632
+ const scrollContainer = mainContent.closest<HTMLElement>('.main-content');
633
+ if (scrollContainer) {
634
+ scrollContainer.scrollTop = 0;
635
+ scrollContainer.scrollLeft = 0;
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ const router = new Router();
642
+ export default router;