create-nativecore 0.1.1 → 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.
- package/README.md +6 -14
- package/bin/index.mjs +402 -431
- package/package.json +3 -2
- package/template/.env.example +28 -0
- package/template/.htmlhintrc +14 -0
- package/template/api/data/dashboard.json +11 -0
- package/template/api/data/users.json +18 -0
- package/template/api/mockApi.js +161 -0
- package/template/assets/icon.svg +13 -0
- package/template/assets/logo.svg +25 -0
- package/template/eslint.config.js +94 -0
- package/template/index.html +137 -0
- package/template/manifest.json +19 -0
- package/template/public/.well-known/security.txt +9 -0
- package/template/public/_headers +24 -0
- package/template/public/_redirects +14 -0
- package/template/public/assets/icon.svg +13 -0
- package/template/public/assets/logo.svg +25 -0
- package/template/public/manifest.json +19 -0
- package/template/public/robots.txt +13 -0
- package/template/public/sitemap.xml +27 -0
- package/template/scripts/build-for-bots.mjs +121 -0
- package/template/scripts/convert-to-ts.mjs +106 -0
- package/template/scripts/fix-encoding.mjs +38 -0
- package/template/scripts/fix-svg-paths.mjs +32 -0
- package/template/scripts/generate-cf-router.mjs +52 -0
- package/template/scripts/inject-dev-tools.mjs +41 -0
- package/template/scripts/inject-version.mjs +65 -0
- package/template/scripts/make-component.mjs +445 -0
- package/template/scripts/make-component.mjs.backup +432 -0
- package/template/scripts/make-controller.mjs +119 -0
- package/template/scripts/make-core-component.mjs +303 -0
- package/template/scripts/make-view.mjs +346 -0
- package/template/scripts/minify.mjs +71 -0
- package/template/scripts/prepare-static-assets.mjs +141 -0
- package/template/scripts/prompt-bot-build.mjs +223 -0
- package/template/scripts/remove-component.mjs +170 -0
- package/template/scripts/remove-core-component.mjs +156 -0
- package/template/scripts/remove-dev.mjs +13 -0
- package/template/scripts/remove-view.mjs +200 -0
- package/template/scripts/strip-dev-blocks.mjs +30 -0
- package/template/scripts/watch-compile.mjs +69 -0
- package/template/server.js +1066 -0
- package/template/src/app.ts +115 -0
- package/template/src/components/appRegistry.ts +8 -0
- package/template/src/components/core/app-footer.ts +27 -0
- package/template/src/components/core/app-header.ts +175 -0
- package/template/src/components/core/app-sidebar.ts +238 -0
- package/template/src/components/core/loading-spinner.ts +25 -0
- package/template/src/components/core/nc-a.ts +313 -0
- package/template/src/components/core/nc-accordion.ts +186 -0
- package/template/src/components/core/nc-alert.ts +153 -0
- package/template/src/components/core/nc-animation.ts +1150 -0
- package/template/src/components/core/nc-autocomplete.ts +271 -0
- package/template/src/components/core/nc-avatar-group.ts +113 -0
- package/template/src/components/core/nc-avatar.ts +148 -0
- package/template/src/components/core/nc-badge.ts +86 -0
- package/template/src/components/core/nc-bottom-nav.ts +214 -0
- package/template/src/components/core/nc-breadcrumb.ts +96 -0
- package/template/src/components/core/nc-button.ts +307 -0
- package/template/src/components/core/nc-card.ts +160 -0
- package/template/src/components/core/nc-checkbox.ts +282 -0
- package/template/src/components/core/nc-chip.ts +115 -0
- package/template/src/components/core/nc-code.ts +314 -0
- package/template/src/components/core/nc-collapsible.ts +154 -0
- package/template/src/components/core/nc-color-picker.ts +268 -0
- package/template/src/components/core/nc-copy-button.ts +119 -0
- package/template/src/components/core/nc-date-picker.ts +443 -0
- package/template/src/components/core/nc-div.ts +280 -0
- package/template/src/components/core/nc-divider.ts +81 -0
- package/template/src/components/core/nc-drawer.ts +230 -0
- package/template/src/components/core/nc-dropdown.ts +178 -0
- package/template/src/components/core/nc-empty-state.ts +134 -0
- package/template/src/components/core/nc-file-upload.ts +354 -0
- package/template/src/components/core/nc-form.ts +312 -0
- package/template/src/components/core/nc-image.ts +184 -0
- package/template/src/components/core/nc-input.ts +383 -0
- package/template/src/components/core/nc-kbd.ts +48 -0
- package/template/src/components/core/nc-menu-item.ts +193 -0
- package/template/src/components/core/nc-menu.ts +376 -0
- package/template/src/components/core/nc-modal.ts +238 -0
- package/template/src/components/core/nc-nav-item.ts +151 -0
- package/template/src/components/core/nc-number-input.ts +350 -0
- package/template/src/components/core/nc-otp-input.ts +235 -0
- package/template/src/components/core/nc-pagination.ts +178 -0
- package/template/src/components/core/nc-popover.ts +260 -0
- package/template/src/components/core/nc-progress-circular.ts +119 -0
- package/template/src/components/core/nc-progress.ts +134 -0
- package/template/src/components/core/nc-radio.ts +235 -0
- package/template/src/components/core/nc-rating.ts +266 -0
- package/template/src/components/core/nc-rich-text.ts +283 -0
- package/template/src/components/core/nc-scroll-top.ts +116 -0
- package/template/src/components/core/nc-select.ts +452 -0
- package/template/src/components/core/nc-skeleton.ts +107 -0
- package/template/src/components/core/nc-slider.ts +285 -0
- package/template/src/components/core/nc-snackbar.ts +230 -0
- package/template/src/components/core/nc-splash.ts +343 -0
- package/template/src/components/core/nc-stepper.ts +247 -0
- package/template/src/components/core/nc-switch.ts +281 -0
- package/template/src/components/core/nc-tab-item.ts +138 -0
- package/template/src/components/core/nc-table.ts +279 -0
- package/template/src/components/core/nc-tabs.ts +554 -0
- package/template/src/components/core/nc-tag-input.ts +279 -0
- package/template/src/components/core/nc-textarea.ts +216 -0
- package/template/src/components/core/nc-time-picker.ts +438 -0
- package/template/src/components/core/nc-timeline.ts +186 -0
- package/template/src/components/core/nc-tooltip.ts +143 -0
- package/template/src/components/frameworkRegistry.ts +68 -0
- package/template/src/components/preloadRegistry.ts +28 -0
- package/template/src/components/registry.ts +8 -0
- package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
- package/template/src/constants/apiEndpoints.ts +27 -0
- package/template/src/constants/errorMessages.ts +23 -0
- package/template/src/constants/index.ts +8 -0
- package/template/src/constants/routePaths.ts +15 -0
- package/template/src/constants/storageKeys.ts +18 -0
- package/template/src/controllers/dashboard.controller.ts +200 -0
- package/template/src/controllers/home.controller.ts +21 -0
- package/template/src/controllers/index.ts +11 -0
- package/template/src/controllers/login.controller.ts +131 -0
- package/template/src/core/component.ts +354 -0
- package/template/src/core/errorHandler.ts +85 -0
- package/template/src/core/gpu-animation.ts +604 -0
- package/template/src/core/http.ts +173 -0
- package/template/src/core/lazyComponents.ts +90 -0
- package/template/src/core/router.ts +642 -0
- package/template/src/core/signals.ts +146 -0
- package/template/src/core/state.ts +248 -0
- package/template/src/dev/component-editor.ts +1363 -0
- package/template/src/dev/component-overlay.ts +278 -0
- package/template/src/dev/context-menu.ts +223 -0
- package/template/src/dev/denc-tools.ts +250 -0
- package/template/src/dev/hmr.ts +189 -0
- package/template/src/dev/nfbs.code-workspace +27 -0
- package/template/src/dev/outline-panel.ts +1247 -0
- package/template/src/middleware/auth.middleware.ts +23 -0
- package/template/src/routes/routes.ts +38 -0
- package/template/src/services/api.service.ts +394 -0
- package/template/src/services/auth.service.ts +176 -0
- package/template/src/services/index.ts +8 -0
- package/template/src/services/logger.service.ts +74 -0
- package/template/src/services/storage.service.ts +88 -0
- package/template/src/stores/appStore.ts +57 -0
- package/template/src/stores/uiStore.ts +36 -0
- package/template/src/styles/core-variables.css +219 -0
- package/template/src/styles/core.css +710 -0
- package/template/src/styles/main.css +3164 -0
- package/template/src/styles/variables.css +152 -0
- package/template/src/types/global.d.ts +47 -0
- package/template/src/utils/cacheBuster.ts +20 -0
- package/template/src/utils/dom.ts +149 -0
- package/template/src/utils/events.ts +203 -0
- package/template/src/utils/form.ts +176 -0
- package/template/src/utils/formatters.ts +169 -0
- package/template/src/utils/helpers.ts +195 -0
- package/template/src/utils/markdown.ts +307 -0
- package/template/src/utils/sidebar.ts +96 -0
- package/template/src/utils/smoothScroll.ts +85 -0
- package/template/src/utils/templates.ts +23 -0
- package/template/src/utils/validation.ts +73 -0
- package/template/src/views/protected/dashboard.html +293 -0
- package/template/src/views/public/home.html +150 -0
- package/template/src/views/public/login.html +102 -0
- package/template/tests/unit/component.test.ts +87 -0
- package/template/tests/unit/computed.test.ts +79 -0
- package/template/tests/unit/form.test.ts +68 -0
- package/template/tests/unit/formatters.test.ts +49 -0
- package/template/tests/unit/lazy-components.test.ts +59 -0
- package/template/tests/unit/markdown.test.ts +62 -0
- package/template/tests/unit/router.test.ts +112 -0
- package/template/tests/unit/signals.test.ts +54 -0
- package/template/tests/unit/validation.test.ts +50 -0
- package/template/tsconfig.build.json +21 -0
- package/template/tsconfig.json +51 -0
- 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;
|