onelaraveljs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/docs/integration_analysis.md +116 -0
- package/docs/onejs_analysis.md +108 -0
- package/docs/optimization_implementation_group2.md +458 -0
- package/docs/optimization_plan.md +130 -0
- package/index.js +16 -0
- package/package.json +13 -0
- package/src/app.js +61 -0
- package/src/core/API.js +72 -0
- package/src/core/ChildrenRegistry.js +410 -0
- package/src/core/DOMBatcher.js +207 -0
- package/src/core/ErrorBoundary.js +226 -0
- package/src/core/EventDelegator.js +416 -0
- package/src/core/Helper.js +817 -0
- package/src/core/LoopContext.js +97 -0
- package/src/core/OneDOM.js +246 -0
- package/src/core/OneMarkup.js +444 -0
- package/src/core/Router.js +996 -0
- package/src/core/SEOConfig.js +321 -0
- package/src/core/SectionEngine.js +75 -0
- package/src/core/TemplateEngine.js +83 -0
- package/src/core/View.js +273 -0
- package/src/core/ViewConfig.js +229 -0
- package/src/core/ViewController.js +1410 -0
- package/src/core/ViewControllerOptimized.js +164 -0
- package/src/core/ViewIdentifier.js +361 -0
- package/src/core/ViewLoader.js +272 -0
- package/src/core/ViewManager.js +1962 -0
- package/src/core/ViewState.js +761 -0
- package/src/core/ViewSystem.js +301 -0
- package/src/core/ViewTemplate.js +4 -0
- package/src/core/helpers/BindingHelper.js +239 -0
- package/src/core/helpers/ConfigHelper.js +37 -0
- package/src/core/helpers/EventHelper.js +172 -0
- package/src/core/helpers/LifecycleHelper.js +17 -0
- package/src/core/helpers/ReactiveHelper.js +169 -0
- package/src/core/helpers/RenderHelper.js +15 -0
- package/src/core/helpers/ResourceHelper.js +89 -0
- package/src/core/helpers/TemplateHelper.js +11 -0
- package/src/core/managers/BindingManager.js +671 -0
- package/src/core/managers/ConfigurationManager.js +136 -0
- package/src/core/managers/EventManager.js +309 -0
- package/src/core/managers/LifecycleManager.js +356 -0
- package/src/core/managers/ReactiveManager.js +334 -0
- package/src/core/managers/RenderEngine.js +292 -0
- package/src/core/managers/ResourceManager.js +441 -0
- package/src/core/managers/ViewHierarchyManager.js +258 -0
- package/src/core/managers/ViewTemplateManager.js +127 -0
- package/src/core/reactive/ReactiveComponent.js +592 -0
- package/src/core/services/EventService.js +418 -0
- package/src/core/services/HttpService.js +106 -0
- package/src/core/services/LoggerService.js +57 -0
- package/src/core/services/StateService.js +512 -0
- package/src/core/services/StorageService.js +856 -0
- package/src/core/services/StoreService.js +258 -0
- package/src/core/services/TemplateDetectorService.js +361 -0
- package/src/core/services/Test.js +18 -0
- package/src/helpers/devWarnings.js +205 -0
- package/src/helpers/performance.js +226 -0
- package/src/helpers/utils.js +287 -0
- package/src/init.js +343 -0
- package/src/plugins/auto-plugin.js +34 -0
- package/src/services/Test.js +18 -0
- package/src/types/index.js +193 -0
- package/src/utils/date-helper.js +51 -0
- package/src/utils/helpers.js +39 -0
- package/src/utils/validation.js +32 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router Module
|
|
3
|
+
* ES6 Module for Blade Compiler
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// import { Application } from "../app.js";
|
|
7
|
+
import { View } from "./View.js";
|
|
8
|
+
|
|
9
|
+
class ActiveRoute {
|
|
10
|
+
constructor(route, urlPath, params, query = {}, fragment = {}) {
|
|
11
|
+
this.$route = route;
|
|
12
|
+
this.$urlPath = urlPath;
|
|
13
|
+
this.$params = params || {};
|
|
14
|
+
this.$query = query || {};
|
|
15
|
+
this.$fragment = fragment || {};
|
|
16
|
+
this.$paramKeys = [...Object.keys(params || {})];
|
|
17
|
+
Object.keys(params).forEach(key => {
|
|
18
|
+
Object.defineProperty(this, key, {
|
|
19
|
+
get: () => this.$params[key],
|
|
20
|
+
set: (value) => {
|
|
21
|
+
this.$params[key] = value;
|
|
22
|
+
},
|
|
23
|
+
enumerable: true,
|
|
24
|
+
configurable: false,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getPath() {
|
|
30
|
+
return this.$urlPath || null;
|
|
31
|
+
}
|
|
32
|
+
getUrlPath() {
|
|
33
|
+
return this.$urlPath || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getParams() {
|
|
37
|
+
return this.$params || {};
|
|
38
|
+
}
|
|
39
|
+
getParam(name) {
|
|
40
|
+
return this.$params[name] || null;
|
|
41
|
+
}
|
|
42
|
+
getQuery() {
|
|
43
|
+
return this.$query || {};
|
|
44
|
+
}
|
|
45
|
+
getFragment() {
|
|
46
|
+
return this.$fragment || {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Router {
|
|
51
|
+
static activeRoute = null;
|
|
52
|
+
static containers = {};
|
|
53
|
+
constructor(App = null) {
|
|
54
|
+
/**
|
|
55
|
+
* @type {Application}
|
|
56
|
+
*/
|
|
57
|
+
this.App = App;
|
|
58
|
+
this.routes = [];
|
|
59
|
+
this.currentRoute = null;
|
|
60
|
+
this.mode = 'history';
|
|
61
|
+
this.base = '';
|
|
62
|
+
this._beforeEach = null;
|
|
63
|
+
this._afterEach = null;
|
|
64
|
+
this.defaultRoute = '/';
|
|
65
|
+
|
|
66
|
+
// Bind methods
|
|
67
|
+
this.handleRoute = this.handleRoute.bind(this);
|
|
68
|
+
this.handlePopState = this.handlePopState.bind(this);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @type {Object<string, {path: string, view: string, params: object}>}
|
|
72
|
+
*/
|
|
73
|
+
this.routeConfigs = {};
|
|
74
|
+
this.currentUri = window.location.pathname + window.location.search;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setApp(app) {
|
|
78
|
+
this.App = app;
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
addRouteConfig(routeConfig) {
|
|
83
|
+
this.routeConfigs[routeConfig.name] = routeConfig;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setAllRoutes(routes) {
|
|
87
|
+
for (const route of routes) {
|
|
88
|
+
this.addRouteConfig(route);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
getURL(name, params = {}) {
|
|
94
|
+
const routeConfig = this.routeConfigs[name];
|
|
95
|
+
if (!routeConfig) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
let url = this.generateUrl(routeConfig.path, params);
|
|
99
|
+
if (!(url.startsWith('/') || url.startsWith('http:') || url.startsWith('https:'))) {
|
|
100
|
+
url = this.base + url;
|
|
101
|
+
}
|
|
102
|
+
return url;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
addRoute(path, view, options = {}) {
|
|
106
|
+
this.routes.push({
|
|
107
|
+
path: this.normalizePath(path),
|
|
108
|
+
view: view,
|
|
109
|
+
options: options
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setMode(mode) {
|
|
114
|
+
this.mode = mode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setBase(base) {
|
|
118
|
+
this.base = base;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setDefaultRoute(route) {
|
|
122
|
+
this.defaultRoute = route;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
beforeEach(callback) {
|
|
126
|
+
this._beforeEach = callback;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
afterEach(callback) {
|
|
130
|
+
this._afterEach = callback;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
normalizePath(path) {
|
|
134
|
+
// Remove leading slash if present, then add it back to ensure consistency
|
|
135
|
+
let normalized = path.startsWith('/') ? path : '/' + path;
|
|
136
|
+
|
|
137
|
+
// Remove trailing slash except for root path
|
|
138
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
139
|
+
normalized = normalized.slice(0, -1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Handle empty path as root
|
|
143
|
+
if (normalized === '') {
|
|
144
|
+
normalized = '/';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return normalized;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* So sánh xem chuỗi str có khớp với format không.
|
|
152
|
+
* @param {string} str - Chuỗi đầu vào cần kiểm tra
|
|
153
|
+
* @param {string} format - Định dạng, ví dụ: {abc}, {abc}-def, demo-{key}, test-demo-{key}.xyz, ...
|
|
154
|
+
* @returns {boolean} true nếu khớp, false nếu không
|
|
155
|
+
*/
|
|
156
|
+
matchFormat(str, format) {
|
|
157
|
+
// Chuyển format thành regex
|
|
158
|
+
// Thay thế {param} thành ([^\/\-.]+) để match 1 đoạn không chứa /, -, .
|
|
159
|
+
// Giữ lại các ký tự đặc biệt như -, ., ...
|
|
160
|
+
let regexStr = format.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); // escape ký tự đặc biệt
|
|
161
|
+
regexStr = regexStr.replace(/\{[a-zA-Z0-9_]+\}/g, '([^\\/\\-.]+)');
|
|
162
|
+
// Cho phép match toàn bộ chuỗi
|
|
163
|
+
regexStr = '^' + regexStr + '$';
|
|
164
|
+
const regex = new RegExp(regexStr);
|
|
165
|
+
return regex.test(str);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
hasParameter(path) {
|
|
169
|
+
return /\{[a-zA-Z0-9_]+\??\}/.test(path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if parameter is optional (has ? suffix)
|
|
174
|
+
* @param {string} path - The path segment to check (e.g., "{name?}")
|
|
175
|
+
* @returns {boolean}
|
|
176
|
+
*/
|
|
177
|
+
isOptionalParameter(path) {
|
|
178
|
+
return /\{[a-zA-Z0-9_]+\?\}/.test(path);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
isAnyParameter(path) {
|
|
182
|
+
return path.includes('*') || path.toLowerCase() === '{any}';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Lấy tên tham số đầu tiên trong path có format {param}
|
|
187
|
+
* Đảm bảo hoạt động trên tất cả các trình duyệt (không dùng lookbehind/lookahead)
|
|
188
|
+
* @param {string} format - Chuỗi path, ví dụ: "abc-{name}.html"
|
|
189
|
+
* @returns {string|null} - Trả về tên param đầu tiên nếu có, ngược lại trả về null
|
|
190
|
+
*/
|
|
191
|
+
getParameterName(format) {
|
|
192
|
+
// Dùng RegExp đơn giản, không lookbehind/lookahead để tương thích trình duyệt cũ
|
|
193
|
+
// Hỗ trợ cả {param} và {param?} (optional)
|
|
194
|
+
var regex = /\{([a-zA-Z0-9_]+)\??\}/;
|
|
195
|
+
var match = regex.exec(format);
|
|
196
|
+
if (match && match[1]) {
|
|
197
|
+
return match[1];
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Lấy giá trị tham số đầu tiên trong path theo format {param} hoặc {param?}
|
|
204
|
+
* @param {string} format - Chuỗi format, ví dụ: "{id}" hoặc "{id?}"
|
|
205
|
+
* @param {string} path - Chuỗi path thực tế, ví dụ: "1"
|
|
206
|
+
* @returns {string|null} - Giá trị param nếu tìm thấy, ngược lại trả về null
|
|
207
|
+
*/
|
|
208
|
+
getParameterValue(format, path) {
|
|
209
|
+
if (typeof path !== 'string' || typeof format !== 'string') return null;
|
|
210
|
+
|
|
211
|
+
// Nếu format chỉ là {param} hoặc {param?} thì trả về path
|
|
212
|
+
if (format.match(/^\{[a-zA-Z0-9_]+\??\}$/)) {
|
|
213
|
+
return path;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Escape các ký tự đặc biệt trong format (trừ {param})
|
|
217
|
+
let regexStr = format.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1');
|
|
218
|
+
// Thay thế {param} hoặc {param?} thành nhóm bắt
|
|
219
|
+
regexStr = regexStr.replace(/\{[a-zA-Z0-9_]+\??\}/g, '([^\\/\\-.]+)');
|
|
220
|
+
// Tạo regex
|
|
221
|
+
const regex = new RegExp('^' + regexStr + '$');
|
|
222
|
+
const match = regex.exec(path);
|
|
223
|
+
if (match && match[1]) {
|
|
224
|
+
return match[1];
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
getAnyParameterValue(format, path) {
|
|
230
|
+
// Nếu format chứa dấu * thì xem như là {any}
|
|
231
|
+
if (typeof format === 'string' && format.includes('*')) {
|
|
232
|
+
format = format.replace(/\*/g, '{any}');
|
|
233
|
+
}
|
|
234
|
+
if (typeof path !== 'string' || typeof format !== 'string') return null;
|
|
235
|
+
// Escape các ký tự đặc biệt trong format (trừ {param})
|
|
236
|
+
let regexStr = format.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1');
|
|
237
|
+
// Thay thế từng {param} thành nhóm bắt
|
|
238
|
+
regexStr = regexStr.replace(/\{[a-zA-Z0-9_]+\}/g, '([^\\/\\-.]+)');
|
|
239
|
+
const regex = new RegExp('^' + regexStr + '$');
|
|
240
|
+
const valueMatch = regex.exec(path);
|
|
241
|
+
if (!valueMatch) return null;
|
|
242
|
+
// Trả về giá trị đầu tiên (hoặc null nếu không có)
|
|
243
|
+
return valueMatch[1] || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check if a route with parameters matches the given path
|
|
248
|
+
* @param {object<{path: string, view: string, params: object}>} route - The route pattern (e.g., "/web/users/{id}")
|
|
249
|
+
* @param {string} urlPath - The actual path to match (e.g., "/web/users/1")
|
|
250
|
+
* @returns {Object|null} - Returns {params} if match, null if no match
|
|
251
|
+
*/
|
|
252
|
+
checkParameterRoute(route, urlPath) {
|
|
253
|
+
const routePathParts = this.App.Helper.trim(route.path, '/').split('/');
|
|
254
|
+
const urlPathParts = this.App.Helper.trim(urlPath, '/').split('/');
|
|
255
|
+
|
|
256
|
+
// Count trailing optional parameters in route
|
|
257
|
+
let trailingOptionalCount = 0;
|
|
258
|
+
for (let i = routePathParts.length - 1; i >= 0; i--) {
|
|
259
|
+
if (this.isOptionalParameter(routePathParts[i])) {
|
|
260
|
+
trailingOptionalCount++;
|
|
261
|
+
} else {
|
|
262
|
+
break; // Stop at first non-optional segment from the end
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// URL can have fewer segments if route has trailing optional params
|
|
267
|
+
// Required segments = total - optional trailing segments
|
|
268
|
+
const requiredSegments = routePathParts.length - trailingOptionalCount;
|
|
269
|
+
|
|
270
|
+
// URL must have at least required segments and at most total segments
|
|
271
|
+
if (urlPathParts.length < requiredSegments || urlPathParts.length > routePathParts.length) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const lastRoutePathIndex = routePathParts.length - 1;
|
|
276
|
+
const params = {};
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < routePathParts.length; i++) {
|
|
279
|
+
const routePathPart = routePathParts[i];
|
|
280
|
+
const urlPathPart = urlPathParts[i]; // May be undefined for optional params
|
|
281
|
+
|
|
282
|
+
if (this.isAnyParameter(routePathPart)) {
|
|
283
|
+
const paramValue = urlPathPart !== undefined
|
|
284
|
+
? this.getAnyParameterValue(routePathPart, urlPathPart)
|
|
285
|
+
: null;
|
|
286
|
+
params.any = paramValue;
|
|
287
|
+
if (i === lastRoutePathIndex && i === 0) {
|
|
288
|
+
return params;
|
|
289
|
+
}
|
|
290
|
+
} else if (this.hasParameter(routePathPart)) {
|
|
291
|
+
const paramName = this.getParameterName(routePathPart);
|
|
292
|
+
const isOptional = this.isOptionalParameter(routePathPart);
|
|
293
|
+
|
|
294
|
+
// If URL segment doesn't exist
|
|
295
|
+
if (urlPathPart === undefined || urlPathPart === '') {
|
|
296
|
+
if (isOptional) {
|
|
297
|
+
// Optional param with no value -> null
|
|
298
|
+
params[paramName] = null;
|
|
299
|
+
continue;
|
|
300
|
+
} else {
|
|
301
|
+
// Required param missing
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const paramValue = this.getParameterValue(routePathPart, urlPathPart);
|
|
307
|
+
if (paramValue === null && !isOptional) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
params[paramName] = paramValue;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
// Regular route part - exact match required
|
|
314
|
+
if (routePathPart !== urlPathPart) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return params;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
matchRoute(path) {
|
|
324
|
+
const normalizedPath = this.normalizePath(path);
|
|
325
|
+
|
|
326
|
+
// Process routes in order - first match wins
|
|
327
|
+
for (const route of this.routes) {
|
|
328
|
+
const routePath = route.path;
|
|
329
|
+
|
|
330
|
+
// Check if route has parameters
|
|
331
|
+
const hasParameters = routePath.split('/').some(part => this.hasParameter(part) || this.isAnyParameter(part));
|
|
332
|
+
|
|
333
|
+
if (hasParameters) {
|
|
334
|
+
// Handle parameter route
|
|
335
|
+
const params = this.checkParameterRoute(route, normalizedPath);
|
|
336
|
+
if (params !== null) {
|
|
337
|
+
return { route, params, path: normalizedPath };
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
// Handle exact route match
|
|
341
|
+
if (routePath === normalizedPath) {
|
|
342
|
+
return { route, params: {}, path: normalizedPath };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async handleRoute(path, ignoreSetActiveRoute = false) {
|
|
351
|
+
// Remove query string for route matching
|
|
352
|
+
const pathForMatching = path.split('?')[0];
|
|
353
|
+
const match = this.matchRoute(pathForMatching);
|
|
354
|
+
|
|
355
|
+
if (!match) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const { route, params } = match;
|
|
360
|
+
|
|
361
|
+
// Call beforeEach hook
|
|
362
|
+
if (this._beforeEach) {
|
|
363
|
+
const result = await this._beforeEach(route, params);
|
|
364
|
+
if (result === false) {
|
|
365
|
+
return; // Navigation cancelled
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const URLParts = Router.getUrlParts();
|
|
369
|
+
const query = URLParts.query;
|
|
370
|
+
const fragment = URLParts.hash;
|
|
371
|
+
|
|
372
|
+
if (!ignoreSetActiveRoute) {
|
|
373
|
+
Router.addActiveRoute(route, match.path, params, query, fragment);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Update current route
|
|
377
|
+
this.currentRoute = { ...route, $params: params, $query: query, $fragment: fragment, $urlPath: match.path };
|
|
378
|
+
|
|
379
|
+
// Render view
|
|
380
|
+
if (this.App.View && (route.view || route.component)) {
|
|
381
|
+
this.App.View.mountView(route.view || route.component, params, Router.getActiveRoute());
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Call afterEach hook with proper arguments: (to, from)
|
|
385
|
+
// to = route object with path property
|
|
386
|
+
if (this._afterEach) {
|
|
387
|
+
const toRoute = { ...route, path: match.path };
|
|
388
|
+
this._afterEach(toRoute, this.currentRoute);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Hydrate server-rendered views
|
|
394
|
+
* Scans SSR HTML and attaches JavaScript behavior without re-rendering
|
|
395
|
+
*
|
|
396
|
+
* Flow:
|
|
397
|
+
* 1. Get active route (from Router.activeRoute)
|
|
398
|
+
* 2. Call beforeEach hook
|
|
399
|
+
* 3. Call View.scanView() to:
|
|
400
|
+
* - Scan page view + all parent layouts
|
|
401
|
+
* - Setup view hierarchy (virtualRender)
|
|
402
|
+
* - Attach event handlers to existing DOM
|
|
403
|
+
* - Setup state subscriptions
|
|
404
|
+
* - Mount all views bottom-up
|
|
405
|
+
* 4. Call afterEach hook
|
|
406
|
+
* 5. Mark hydration complete
|
|
407
|
+
*/
|
|
408
|
+
async hydrateViews() {
|
|
409
|
+
|
|
410
|
+
if (!this.App?.View) {
|
|
411
|
+
console.error('❌ Router.hydrateViews: App.View not available');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Get active route info
|
|
416
|
+
const activeRoute = Router.activeRoute || this.currentRoute;
|
|
417
|
+
|
|
418
|
+
if (!activeRoute) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const { $route: route, $params: params, $urlPath: urlPath, $query: query, $fragment: fragment } = activeRoute;
|
|
423
|
+
|
|
424
|
+
// Call beforeEach hook (if exists)
|
|
425
|
+
if (this._beforeEach) {
|
|
426
|
+
const result = await this._beforeEach(route, params, urlPath);
|
|
427
|
+
if (result === false) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Handle view hydration
|
|
433
|
+
if (route.view || route.component) {
|
|
434
|
+
try {
|
|
435
|
+
const viewName = route.view || route.component;
|
|
436
|
+
|
|
437
|
+
this.App.View.mountViewScan(viewName, params, activeRoute);
|
|
438
|
+
// Call afterEach hook with proper arguments: (to, from)
|
|
439
|
+
if (this._afterEach) {
|
|
440
|
+
const toRoute = { ...route, path: urlPath };
|
|
441
|
+
this._afterEach(toRoute, this.currentRoute);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
} catch (error) {
|
|
446
|
+
console.error('❌ Router.hydrateViews: Error during hydration:', error);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
handlePopState(event) {
|
|
453
|
+
const path = window.location.pathname + window.location.search;
|
|
454
|
+
this.currentUri = path; // Update currentUri during back/forward navigation
|
|
455
|
+
this.handleRoute(path);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
navigate(path) {
|
|
459
|
+
|
|
460
|
+
if (this.mode === 'history') {
|
|
461
|
+
window.history.pushState({}, '', path);
|
|
462
|
+
try {
|
|
463
|
+
this.handleRoute(path);
|
|
464
|
+
this.currentUri = path;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
console.error('❌ Router.navigate handleRoute error:', error);
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
// Hash mode
|
|
470
|
+
window.location.hash = path;
|
|
471
|
+
try {
|
|
472
|
+
this.handleRoute(path);
|
|
473
|
+
this.currentUri = path;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.error('❌ Router.navigate handleRoute (hash mode) error:', error);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Generate URL with file extension
|
|
482
|
+
* @param {string} route - Route pattern (e.g., '/users/{id}')
|
|
483
|
+
* @param {object} params - Parameters to fill in
|
|
484
|
+
* @param {string} extension - File extension (e.g., '.html', '.php')
|
|
485
|
+
* @returns {string} Generated URL
|
|
486
|
+
*/
|
|
487
|
+
generateUrl(route, params = {}, extension = '') {
|
|
488
|
+
let url = route;
|
|
489
|
+
|
|
490
|
+
// Replace parameters
|
|
491
|
+
for (const [key, value] of Object.entries(params)) {
|
|
492
|
+
url = url.replace(`{${key}}`, value);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Add extension if provided
|
|
496
|
+
if (extension && !url.endsWith(extension)) {
|
|
497
|
+
url += extension;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return url;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Navigate to route with file extension
|
|
505
|
+
* @param {string} route - Route pattern
|
|
506
|
+
* @param {object} params - Parameters
|
|
507
|
+
* @param {string} extension - File extension
|
|
508
|
+
*/
|
|
509
|
+
navigateTo(route, params = {}, extension = '') {
|
|
510
|
+
const url = this.generateUrl(route, params, extension);
|
|
511
|
+
this.navigate(url);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
start(skipInitial = false) {
|
|
515
|
+
// Detect if page has server-rendered content
|
|
516
|
+
// Check container for data-server-rendered attribute
|
|
517
|
+
const container = this.App?.View?.container || document.querySelector('#spa-content, #app-root, #app');
|
|
518
|
+
const serverRendered = container.getAttribute('data-server-rendered');
|
|
519
|
+
const isServerRendered = serverRendered === 'true';
|
|
520
|
+
const URLParts = Router.getUrlParts();
|
|
521
|
+
const initialPath = this.mode === 'history' ? (window.location.pathname + window.location.search) : (window.location.hash.substring(1) || this.defaultRoute);
|
|
522
|
+
|
|
523
|
+
this.setActiveRouteForCurrentPath(initialPath);
|
|
524
|
+
|
|
525
|
+
// Add event listeners
|
|
526
|
+
if (this.mode === 'history') {
|
|
527
|
+
window.addEventListener('popstate', this.handlePopState);
|
|
528
|
+
} else {
|
|
529
|
+
window.addEventListener('hashchange', this.handlePopState);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Choose initial rendering strategy
|
|
533
|
+
if (isServerRendered) {
|
|
534
|
+
// SSR: Hydrate existing HTML
|
|
535
|
+
console.log('🚀 Router.start: Starting SSR hydration...');
|
|
536
|
+
this.hydrateViews();
|
|
537
|
+
} else if (!skipInitial) {
|
|
538
|
+
// CSR: Render from scratch
|
|
539
|
+
this.handleRoute(initialPath);
|
|
540
|
+
} else {
|
|
541
|
+
console.log('🔍 Router.start: Skipping initial route handling' + (this.mode === 'history' ? '' : '( hash )') + ' but activeRoute is set');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
this.setupAutoNavigation();
|
|
545
|
+
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
stop() {
|
|
549
|
+
if (this.mode === 'history') {
|
|
550
|
+
window.removeEventListener('popstate', this.handlePopState);
|
|
551
|
+
} else {
|
|
552
|
+
window.removeEventListener('hashchange', this.handlePopState);
|
|
553
|
+
}
|
|
554
|
+
// Remove auto-navigation listener
|
|
555
|
+
document.removeEventListener('click', this._autoNavHandler);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Set activeRoute for current path without rendering view
|
|
560
|
+
* Used when starting router with skipInitial = true
|
|
561
|
+
*/
|
|
562
|
+
setActiveRouteForCurrentPath(path) {
|
|
563
|
+
// console.log('🔍 setActiveRouteForCurrentPath called with:', path);
|
|
564
|
+
// Remove query string for route matching
|
|
565
|
+
const pathForMatching = path.split('?')[0];
|
|
566
|
+
const match = this.matchRoute(pathForMatching);
|
|
567
|
+
|
|
568
|
+
if (match) {
|
|
569
|
+
const { route, params } = match;
|
|
570
|
+
const URLParts = Router.getUrlParts();
|
|
571
|
+
const query = URLParts.query;
|
|
572
|
+
const fragment = URLParts.hash;
|
|
573
|
+
// console.log('✅ Setting activeRoute for current path:', route.path, 'with params:', params);
|
|
574
|
+
|
|
575
|
+
// Set activeRoute without rendering
|
|
576
|
+
Router.addActiveRoute(route, match.path, params, query, fragment);
|
|
577
|
+
this.currentRoute = { ...route, $params: params, $query: query, $fragment: fragment, $urlPath: match.path };
|
|
578
|
+
|
|
579
|
+
// console.log('✅ activeRoute set successfully:', Router.activeRoute);
|
|
580
|
+
} else {
|
|
581
|
+
console.log('❌ No matching route found for current path:', path);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Setup auto-detect navigation for internal links
|
|
587
|
+
*/
|
|
588
|
+
setupAutoNavigation() {
|
|
589
|
+
// console.log('🔍 setupAutoNavigation called');
|
|
590
|
+
// Store reference to handler for removal
|
|
591
|
+
this._autoNavHandler = this.handleAutoNavigation.bind(this);
|
|
592
|
+
document.addEventListener('click', this._autoNavHandler);
|
|
593
|
+
// console.log('✅ Auto-navigation setup complete - event listener added');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Handle auto-detect navigation
|
|
598
|
+
* @param {Event} e - Click event
|
|
599
|
+
*/
|
|
600
|
+
handleAutoNavigation(e) {
|
|
601
|
+
// console.log('🔍 handleAutoNavigation called for:', e.target);
|
|
602
|
+
|
|
603
|
+
// Check for data-nav-link attribute first (highest priority)
|
|
604
|
+
const oneNavElement = e.target.closest('[data-nav-link]');
|
|
605
|
+
if (oneNavElement) {
|
|
606
|
+
// Check if navigation is disabled
|
|
607
|
+
if (oneNavElement.hasAttribute('data-nav-disabled')) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const navPath = oneNavElement.getAttribute('data-nav-link');
|
|
612
|
+
|
|
613
|
+
if (navPath && navPath.trim() !== '') {
|
|
614
|
+
e.preventDefault();
|
|
615
|
+
if (navPath === this.currentUri) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
this.navigate(navPath);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Check for data-navigate attribute (alternative to data-nav-link)
|
|
624
|
+
const navigateElement = e.target.closest('[data-navigate]');
|
|
625
|
+
if (navigateElement) {
|
|
626
|
+
// Check if navigation is disabled
|
|
627
|
+
if (navigateElement.hasAttribute('data-nav-disabled')) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const navPath = navigateElement.getAttribute('data-navigate');
|
|
632
|
+
|
|
633
|
+
if (navPath && navPath.trim() !== '') {
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
if (navPath === this.currentUri) {
|
|
636
|
+
console.log('🚫 Same path - no navigation needed:', navPath);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
this.navigate(navPath);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Fallback to traditional <a> tag handling
|
|
645
|
+
const link = e.target.closest('a[href]');
|
|
646
|
+
if (!link) return;
|
|
647
|
+
if(Router.isCurrentPath(link.href, this.mode)){
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if(this.mode !== "hash" && link.href.startsWith('#')){
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// Skip if link has target="_blank"
|
|
654
|
+
if (link.target === '_blank') {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Skip if link has data-nav="disabled" or on-nav="false"
|
|
659
|
+
if (link.dataset.nav === 'disabled' || link.getAttribute('data-nav') === 'false') {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Skip if link has data-nav="false" (explicitly disabled)
|
|
664
|
+
if (link.dataset.nav === 'false') {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Skip mailto, tel, and other special protocols
|
|
669
|
+
if (link.href.startsWith('mailto:') || link.href.startsWith('tel:') || link.href.startsWith('javascript:')) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const href = link.href;
|
|
674
|
+
|
|
675
|
+
// Check if it's an external URL (different domain)
|
|
676
|
+
try {
|
|
677
|
+
const linkUrl = new URL(href);
|
|
678
|
+
const currentUrl = new URL(window.location.href);
|
|
679
|
+
|
|
680
|
+
// If different host, skip (external link)
|
|
681
|
+
if (linkUrl.host !== currentUrl.host) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
} catch (error) {
|
|
685
|
+
// If URL parsing fails, treat as internal link
|
|
686
|
+
console.log('⚠️ URL parsing failed, treating as internal:', href);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Check if it's a full URL with same domain (and not same path - already checked above)
|
|
690
|
+
if (href.startsWith('http://') || href.startsWith('https://')) {
|
|
691
|
+
try {
|
|
692
|
+
const linkUrl = new URL(href);
|
|
693
|
+
const currentUrl = new URL(window.location.href);
|
|
694
|
+
|
|
695
|
+
if (linkUrl.host === currentUrl.host) {
|
|
696
|
+
// Same domain, extract path with query string for navigation
|
|
697
|
+
const path = linkUrl.pathname + linkUrl.search;
|
|
698
|
+
e.preventDefault();
|
|
699
|
+
if (path === this.currentUri) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.navigate(path);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
console.log('⚠️ URL parsing failed for full URL:', href);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Handle relative URLs (and not same path - already checked above)
|
|
711
|
+
if (href && !href.startsWith('http') && !href.startsWith('//')) {
|
|
712
|
+
e.preventDefault();
|
|
713
|
+
if (href === this.currentUri) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
this.navigate(href);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
}
|
|
721
|
+
static getUrlParts() {
|
|
722
|
+
const { location } = window;
|
|
723
|
+
const { search, hash, pathname } = location;
|
|
724
|
+
return {
|
|
725
|
+
url: location.href,
|
|
726
|
+
protocol: location.protocol,
|
|
727
|
+
search: search.startsWith('?') ? search.substring(1) : search,
|
|
728
|
+
path: pathname,
|
|
729
|
+
query: Object.fromEntries(new URLSearchParams(search)),
|
|
730
|
+
hash: hash.startsWith('#') ? hash.substring(1) : hash
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
static addActiveRoute(route, urlPath, params, query = {}, fragment = null) {
|
|
735
|
+
if (!route.path) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (!Router.containers[route.path]) {
|
|
739
|
+
Router.containers[route.path] = new ActiveRoute(route, urlPath, params, query, fragment);
|
|
740
|
+
Router.activeRoute = Router.containers[route.path];
|
|
741
|
+
} else {
|
|
742
|
+
Router.containers[route.path].$urlPath = urlPath;
|
|
743
|
+
Router.containers[route.path].$params = params;
|
|
744
|
+
Router.containers[route.path].$query = query;
|
|
745
|
+
Router.containers[route.path].$fragment = fragment;
|
|
746
|
+
Router.activeRoute = Router.containers[route.path];
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Kiểm tra xem URL có khớp với route hiện tại không
|
|
752
|
+
* @param {string} url - URL cần kiểm tra (có thể là rỗng, hash, query, relative, hoặc full URL)
|
|
753
|
+
* @param {string} mode - Chế độ routing: 'history' hoặc 'hash' (mặc định: 'history')
|
|
754
|
+
* @returns {boolean} - true nếu URL khớp với route hiện tại
|
|
755
|
+
*
|
|
756
|
+
* Xử lý các định dạng URL khác nhau:
|
|
757
|
+
*
|
|
758
|
+
* Mode 'history':
|
|
759
|
+
* - Chuỗi rỗng: trả về false
|
|
760
|
+
* - Hash fragment (#abc): cùng path (chỉ khác hash fragment)
|
|
761
|
+
* - Query string (?name=value): cùng path (chỉ khác query)
|
|
762
|
+
* - Absolute path (/path/to/page): so sánh pathname và search
|
|
763
|
+
* - Relative path (abc, abc/def): so sánh với segment(s) cuối của current path
|
|
764
|
+
* - Full URL (http://example.com/path): parse và so sánh pathname + search
|
|
765
|
+
*
|
|
766
|
+
* Mode 'hash':
|
|
767
|
+
* - Chỉ chấp nhận: "/abc...", "abc...", hoặc "[schema]://[host]/#/abc..."
|
|
768
|
+
* - Từ chối: "#abc" (standalone), "?name=value", "http://..." (không có hash)
|
|
769
|
+
* - So sánh với hash path từ window.location.hash
|
|
770
|
+
*/
|
|
771
|
+
static isCurrentPath(url, mode = 'history') {
|
|
772
|
+
// Kiểm tra URL rỗng hoặc null
|
|
773
|
+
if (!url || typeof url !== 'string' || url.trim() === '') {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Lấy route hiện tại
|
|
778
|
+
const route = Router.getActiveRoute();
|
|
779
|
+
if (!route) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const currentPath = route.getPath();
|
|
784
|
+
if (!currentPath) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Chuẩn hóa current path dựa trên mode
|
|
789
|
+
let currentPathname, currentSearch;
|
|
790
|
+
|
|
791
|
+
// Mode hash: lấy path từ hash thay vì pathname
|
|
792
|
+
if (mode === 'hash') {
|
|
793
|
+
// Trong hash mode, current path được lưu trong hash (bỏ ký tự #)
|
|
794
|
+
const hashPath = window.location.hash.substring(1) || '';
|
|
795
|
+
if (hashPath) {
|
|
796
|
+
const hashParts = hashPath.split('?');
|
|
797
|
+
currentPathname = hashParts[0];
|
|
798
|
+
currentSearch = hashParts[1] ? '?' + hashParts[1] : '';
|
|
799
|
+
} else {
|
|
800
|
+
// Nếu hash rỗng, dùng currentPath làm fallback
|
|
801
|
+
const pathParts = currentPath.split('?');
|
|
802
|
+
currentPathname = pathParts[0];
|
|
803
|
+
currentSearch = pathParts[1] ? '?' + pathParts[1] : '';
|
|
804
|
+
}
|
|
805
|
+
} else {
|
|
806
|
+
// History mode: sử dụng pathname và search từ window.location hoặc currentPath
|
|
807
|
+
try {
|
|
808
|
+
// Thử parse current path như URL (có thể là relative)
|
|
809
|
+
if (currentPath.startsWith('http://') || currentPath.startsWith('https://')) {
|
|
810
|
+
const currentUrl = new URL(currentPath);
|
|
811
|
+
currentPathname = currentUrl.pathname;
|
|
812
|
+
currentSearch = currentUrl.search;
|
|
813
|
+
} else {
|
|
814
|
+
// Relative path - sử dụng trực tiếp
|
|
815
|
+
const pathParts = currentPath.split('?');
|
|
816
|
+
currentPathname = pathParts[0];
|
|
817
|
+
currentSearch = pathParts[1] ? '?' + pathParts[1] : '';
|
|
818
|
+
}
|
|
819
|
+
} catch (e) {
|
|
820
|
+
// Fallback: sử dụng currentPath trực tiếp
|
|
821
|
+
const pathParts = currentPath.split('?');
|
|
822
|
+
currentPathname = pathParts[0];
|
|
823
|
+
currentSearch = pathParts[1] ? '?' + pathParts[1] : '';
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Chuẩn hóa input URL
|
|
828
|
+
const normalizedUrl = url.trim();
|
|
829
|
+
|
|
830
|
+
// Hash mode: chỉ chấp nhận "/abc...", "abc...", hoặc "[schema]://[host]/#/abc..."
|
|
831
|
+
if (mode === 'hash') {
|
|
832
|
+
// Từ chối standalone hash fragment (#abc không có ://) hoặc query string (?name=value)
|
|
833
|
+
if ((normalizedUrl.startsWith('#') && !normalizedUrl.includes('://')) ||
|
|
834
|
+
normalizedUrl.startsWith('?')) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let inputPathname, inputSearch;
|
|
839
|
+
|
|
840
|
+
// Trường hợp: Full URL với hash [schema]://[host]/#/abc...
|
|
841
|
+
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
|
|
842
|
+
try {
|
|
843
|
+
const inputUrl = new URL(normalizedUrl);
|
|
844
|
+
|
|
845
|
+
// Kiểm tra host có khớp với window.location.host không
|
|
846
|
+
if (inputUrl.host !== window.location.host) {
|
|
847
|
+
return false; // Host khác nhau
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Trích xuất hash path (bỏ ký tự #)
|
|
851
|
+
const hashPath = inputUrl.hash.substring(1) || '';
|
|
852
|
+
if (!hashPath) {
|
|
853
|
+
return false; // Không có hash path trong URL
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Parse hash path
|
|
857
|
+
const hashParts = hashPath.split('?');
|
|
858
|
+
inputPathname = hashParts[0];
|
|
859
|
+
inputSearch = hashParts[1] ? '?' + hashParts[1] : '';
|
|
860
|
+
} catch (e) {
|
|
861
|
+
return false; // Định dạng URL không hợp lệ
|
|
862
|
+
}
|
|
863
|
+
} else {
|
|
864
|
+
// Trường hợp: Absolute path (/abc...) hoặc relative path (abc...)
|
|
865
|
+
// Bỏ hash và query để so sánh
|
|
866
|
+
const urlWithoutHash = normalizedUrl.split('#')[0];
|
|
867
|
+
const pathParts = urlWithoutHash.split('?');
|
|
868
|
+
inputPathname = pathParts[0];
|
|
869
|
+
inputSearch = pathParts[1] ? '?' + pathParts[1] : '';
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// So sánh với current hash path
|
|
873
|
+
return currentPathname === inputPathname && currentSearch === inputSearch;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// History mode: xử lý tất cả các định dạng URL như trước
|
|
877
|
+
// Trường hợp 1: Hash fragment (#abc) - cùng path, chỉ khác hash fragment
|
|
878
|
+
if (normalizedUrl.startsWith('#')) {
|
|
879
|
+
return true; // Cùng path, khác hash fragment
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Trường hợp 2: Query string (?name=value) - cùng path, chỉ khác query
|
|
883
|
+
if (normalizedUrl.startsWith('?')) {
|
|
884
|
+
return true; // Cùng path, khác query
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Trường hợp 3: Absolute path (/path/to/page)
|
|
888
|
+
if (normalizedUrl.startsWith('/')) {
|
|
889
|
+
try {
|
|
890
|
+
// Bỏ hash nếu có (trong history mode, hash là fragment)
|
|
891
|
+
const urlWithoutHash = normalizedUrl.split('#')[0];
|
|
892
|
+
const pathParts = urlWithoutHash.split('?');
|
|
893
|
+
const inputPathname = pathParts[0];
|
|
894
|
+
const inputSearch = pathParts[1] ? '?' + pathParts[1] : '';
|
|
895
|
+
|
|
896
|
+
// So sánh pathname và search
|
|
897
|
+
return currentPathname === inputPathname && currentSearch === inputSearch;
|
|
898
|
+
} catch (e) {
|
|
899
|
+
// Nếu parse thất bại, so sánh chuỗi đơn giản
|
|
900
|
+
const urlWithoutHash = normalizedUrl.split('#')[0];
|
|
901
|
+
return currentPath === urlWithoutHash || currentPathname === urlWithoutHash;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Trường hợp 4: Full URL (http://example.com/path) - chỉ trong history mode
|
|
906
|
+
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
|
|
907
|
+
try {
|
|
908
|
+
const inputUrl = new URL(normalizedUrl);
|
|
909
|
+
const inputPathname = inputUrl.pathname;
|
|
910
|
+
const inputSearch = inputUrl.search;
|
|
911
|
+
|
|
912
|
+
// So sánh với window.location nếu currentPath là relative
|
|
913
|
+
if (!currentPath.startsWith('http://') && !currentPath.startsWith('https://')) {
|
|
914
|
+
// Current path là relative, so sánh với window.location
|
|
915
|
+
return window.location.pathname === inputPathname &&
|
|
916
|
+
window.location.search === inputSearch;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Cả hai đều là full URL, so sánh trực tiếp
|
|
920
|
+
return currentPathname === inputPathname && currentSearch === inputSearch;
|
|
921
|
+
} catch (e) {
|
|
922
|
+
return false; // Định dạng URL không hợp lệ
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Trường hợp 5: Relative path (abc, abc/def, ...) - so sánh với segment(s) cuối của current path
|
|
927
|
+
// Bỏ hash và query từ relative URL
|
|
928
|
+
const relativeUrl = normalizedUrl.split('#')[0].split('?')[0];
|
|
929
|
+
|
|
930
|
+
// Lấy segment(s) cuối của current pathname
|
|
931
|
+
const currentSegments = currentPathname.split('/').filter(s => s);
|
|
932
|
+
const inputSegments = relativeUrl.split('/').filter(s => s);
|
|
933
|
+
|
|
934
|
+
// Nếu input có nhiều segments hơn current, không thể khớp
|
|
935
|
+
if (inputSegments.length > currentSegments.length) {
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// So sánh N segments cuối, trong đó N = inputSegments.length
|
|
940
|
+
const startIndex = currentSegments.length - inputSegments.length;
|
|
941
|
+
for (let i = 0; i < inputSegments.length; i++) {
|
|
942
|
+
if (currentSegments[startIndex + i] !== inputSegments[i]) {
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
static getCachedRoute(routePath) {
|
|
951
|
+
return Router.containers[routePath] || null;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Get current route
|
|
956
|
+
* @returns {ActiveRoute} current route
|
|
957
|
+
*/
|
|
958
|
+
static getCurrentRoute() {
|
|
959
|
+
return Router.activeRoute || null;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Get active route
|
|
963
|
+
* @returns {ActiveRoute} active route
|
|
964
|
+
*/
|
|
965
|
+
static getActiveRoute() {
|
|
966
|
+
return Router.activeRoute || null;
|
|
967
|
+
}
|
|
968
|
+
static getCurrentPath() {
|
|
969
|
+
return window.location.pathname;
|
|
970
|
+
}
|
|
971
|
+
static getCurrentUri() {
|
|
972
|
+
return this.mode === 'history' ? Router.getCurrentPath() : Router.getCurrentHash();
|
|
973
|
+
}
|
|
974
|
+
static getCurrentHash() {
|
|
975
|
+
const fragmentString = window.location.hash.substring(1) || '';
|
|
976
|
+
return fragmentString;
|
|
977
|
+
}
|
|
978
|
+
static getCurrentUrl() {
|
|
979
|
+
return this.mode === 'history' ? Router.getCurrentPath() + window.location.search : Router.getCurrentHash();
|
|
980
|
+
}
|
|
981
|
+
static getCurrentQuery() {
|
|
982
|
+
const query = Object.fromEntries(new URLSearchParams(window.location.search));
|
|
983
|
+
return query;
|
|
984
|
+
}
|
|
985
|
+
static getCurrentFragment() {
|
|
986
|
+
const fragmentString = window.location.hash.substring(1) || '';
|
|
987
|
+
return fragmentString;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
export default Router;
|
|
992
|
+
|
|
993
|
+
export const useRoute = () => Router.getCurrentRoute();
|
|
994
|
+
export const useParams = () => useRoute()?.getParams() || {};
|
|
995
|
+
export const useQuery = () => Router.getCurrentQuery() || {};
|
|
996
|
+
export const useFragment = () => Router.getCurrentFragment() || {};
|