@erickxavier/no-js 1.7.0 → 1.8.1
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 +19 -10
- package/dist/cjs/no.js +5 -5
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +5 -5
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +5 -5
- package/dist/iife/no.js.map +3 -3
- package/package.json +1 -1
- package/src/directives/refs.js +71 -4
- package/src/directives/validation.js +1 -1
- package/src/dom.js +15 -1
- package/src/globals.js +1 -1
- package/src/index.js +28 -5
- package/src/router.js +103 -21
package/package.json
CHANGED
package/src/directives/refs.js
CHANGED
|
@@ -6,8 +6,12 @@ import {
|
|
|
6
6
|
_refs,
|
|
7
7
|
_stores,
|
|
8
8
|
_notifyStoreWatchers,
|
|
9
|
+
_emitEvent,
|
|
10
|
+
_routerInstance,
|
|
11
|
+
_warn,
|
|
9
12
|
_onDispose,
|
|
10
13
|
} from "../globals.js";
|
|
14
|
+
import { _devtoolsEmit } from "../devtools.js";
|
|
11
15
|
import { createContext } from "../context.js";
|
|
12
16
|
import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
|
|
13
17
|
import { _doFetch } from "../fetch.js";
|
|
@@ -75,19 +79,41 @@ registerDirective("call", {
|
|
|
75
79
|
init(el, name, url) {
|
|
76
80
|
const ctx = findContext(el);
|
|
77
81
|
const method = el.getAttribute("method") || "get";
|
|
78
|
-
const asKey = el.getAttribute("as");
|
|
82
|
+
const asKey = el.getAttribute("as") || "data";
|
|
79
83
|
const intoStore = el.getAttribute("into");
|
|
80
84
|
const successTpl = el.getAttribute("success");
|
|
81
85
|
const errorTpl = el.getAttribute("error");
|
|
86
|
+
const loadingTpl = el.getAttribute("loading");
|
|
82
87
|
const thenExpr = el.getAttribute("then");
|
|
83
88
|
const confirmMsg = el.getAttribute("confirm");
|
|
84
89
|
const bodyAttr = el.getAttribute("body");
|
|
90
|
+
const redirectPath = el.getAttribute("redirect");
|
|
91
|
+
const headersAttr = el.getAttribute("headers");
|
|
92
|
+
|
|
93
|
+
const originalChildren = [...el.childNodes].map((n) => n.cloneNode(true));
|
|
94
|
+
let _activeAbort = null;
|
|
85
95
|
|
|
86
96
|
el.addEventListener("click", async (e) => {
|
|
87
97
|
e.preventDefault();
|
|
88
98
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
89
99
|
|
|
100
|
+
// SwitchMap: abort previous in-flight request
|
|
101
|
+
if (_activeAbort) _activeAbort.abort();
|
|
102
|
+
_activeAbort = new AbortController();
|
|
103
|
+
|
|
90
104
|
const resolvedUrl = _interpolate(url, ctx);
|
|
105
|
+
|
|
106
|
+
// Show loading template
|
|
107
|
+
if (loadingTpl) {
|
|
108
|
+
const clone = _cloneTemplate(loadingTpl);
|
|
109
|
+
if (clone) {
|
|
110
|
+
el.innerHTML = "";
|
|
111
|
+
el.appendChild(clone);
|
|
112
|
+
processTree(el);
|
|
113
|
+
el.disabled = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
try {
|
|
92
118
|
let reqBody = null;
|
|
93
119
|
if (bodyAttr) {
|
|
@@ -98,9 +124,27 @@ registerDirective("call", {
|
|
|
98
124
|
reqBody = interpolated;
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
|
-
|
|
127
|
+
|
|
128
|
+
const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
|
|
129
|
+
const data = await _doFetch(
|
|
130
|
+
resolvedUrl,
|
|
131
|
+
method,
|
|
132
|
+
reqBody,
|
|
133
|
+
extraHeaders,
|
|
134
|
+
el,
|
|
135
|
+
_activeAbort.signal,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Restore original children
|
|
139
|
+
if (loadingTpl) {
|
|
140
|
+
el.innerHTML = "";
|
|
141
|
+
for (const child of originalChildren)
|
|
142
|
+
el.appendChild(child.cloneNode(true));
|
|
143
|
+
el.disabled = false;
|
|
144
|
+
}
|
|
145
|
+
|
|
102
146
|
if (asKey) ctx.$set(asKey, data);
|
|
103
|
-
if (
|
|
147
|
+
if (intoStore) {
|
|
104
148
|
if (!_stores[intoStore]) _stores[intoStore] = createContext({});
|
|
105
149
|
_stores[intoStore].$set(asKey, data);
|
|
106
150
|
_notifyStoreWatchers();
|
|
@@ -123,14 +167,37 @@ registerDirective("call", {
|
|
|
123
167
|
processTree(wrapper);
|
|
124
168
|
}
|
|
125
169
|
}
|
|
170
|
+
|
|
171
|
+
if (redirectPath && _routerInstance)
|
|
172
|
+
_routerInstance.push(redirectPath);
|
|
173
|
+
|
|
174
|
+
_emitEvent("fetch:success", { url: resolvedUrl, data });
|
|
175
|
+
_devtoolsEmit("fetch:success", { method, url: resolvedUrl });
|
|
126
176
|
} catch (err) {
|
|
177
|
+
// SwitchMap: silently ignore aborted requests
|
|
178
|
+
if (err.name === "AbortError") return;
|
|
179
|
+
|
|
180
|
+
_warn(`call ${method.toUpperCase()} ${resolvedUrl} failed:`, err.message);
|
|
181
|
+
|
|
182
|
+
// Restore original children
|
|
183
|
+
if (loadingTpl) {
|
|
184
|
+
el.innerHTML = "";
|
|
185
|
+
for (const child of originalChildren)
|
|
186
|
+
el.appendChild(child.cloneNode(true));
|
|
187
|
+
el.disabled = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_emitEvent("fetch:error", { url: resolvedUrl, error: err });
|
|
191
|
+
_emitEvent("error", { url: resolvedUrl, error: err });
|
|
192
|
+
_devtoolsEmit("fetch:error", { method, url: resolvedUrl, error: err.message });
|
|
193
|
+
|
|
127
194
|
if (errorTpl) {
|
|
128
195
|
const clone = _cloneTemplate(errorTpl);
|
|
129
196
|
if (clone) {
|
|
130
197
|
const tplEl = document.getElementById(errorTpl.replace("#", ""));
|
|
131
198
|
const vn = tplEl?.getAttribute("var") || "err";
|
|
132
199
|
const childCtx = createContext(
|
|
133
|
-
{ [vn]: { message: err.message, status: err.status } },
|
|
200
|
+
{ [vn]: { message: err.message, status: err.status, body: err.body } },
|
|
134
201
|
ctx,
|
|
135
202
|
);
|
|
136
203
|
const target = el.parentElement;
|
|
@@ -316,7 +316,7 @@ registerDirective("validate", {
|
|
|
316
316
|
// $form.valid reflects real state (keeps submit disabled)
|
|
317
317
|
if (!fieldValid) valid = false;
|
|
318
318
|
|
|
319
|
-
// $form.errors only shows errors for
|
|
319
|
+
// $form.errors only shows errors for interacted fields
|
|
320
320
|
if (!fieldValid && fieldInteracted) {
|
|
321
321
|
errors[field.name] = topError.message;
|
|
322
322
|
errorCount++;
|
package/src/dom.js
CHANGED
|
@@ -84,6 +84,11 @@ export async function _loadRemoteTemplates(root) {
|
|
|
84
84
|
_log("[LRT] CACHE HIT:", resolvedUrl);
|
|
85
85
|
} else {
|
|
86
86
|
const res = await fetch(resolvedUrl);
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
_warn("Failed to load template:", src, "HTTP", res.status);
|
|
89
|
+
tpl.__loadFailed = true;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
87
92
|
html = await res.text();
|
|
88
93
|
if (useCache) _templateHtmlCache.set(resolvedUrl, html);
|
|
89
94
|
}
|
|
@@ -148,6 +153,12 @@ export async function _loadTemplateElement(tpl) {
|
|
|
148
153
|
_log("[LTE] CACHE HIT:", resolvedUrl);
|
|
149
154
|
} else {
|
|
150
155
|
const res = await fetch(resolvedUrl);
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
_warn("Failed to load template:", src, "HTTP", res.status);
|
|
158
|
+
tpl.__loadFailed = true;
|
|
159
|
+
if (loadingMarker) loadingMarker.remove();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
151
162
|
html = await res.text();
|
|
152
163
|
if (useCache) _templateHtmlCache.set(resolvedUrl, html);
|
|
153
164
|
}
|
|
@@ -170,7 +181,10 @@ export async function _loadTemplateElement(tpl) {
|
|
|
170
181
|
const subSrc = sub.getAttribute("src");
|
|
171
182
|
const subUrl = _resolveTemplateSrc(subSrc, sub);
|
|
172
183
|
if (_templateHtmlCache.has(subUrl)) return;
|
|
173
|
-
return fetch(subUrl).then((r) =>
|
|
184
|
+
return fetch(subUrl).then((r) => {
|
|
185
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
186
|
+
return r.text();
|
|
187
|
+
}).then((h) => {
|
|
174
188
|
_templateHtmlCache.set(subUrl, h);
|
|
175
189
|
}).catch(() => {});
|
|
176
190
|
});
|
package/src/globals.js
CHANGED
|
@@ -12,7 +12,7 @@ export const _config = {
|
|
|
12
12
|
csrf: null,
|
|
13
13
|
cache: { strategy: "none", ttl: 300000 },
|
|
14
14
|
templates: { cache: true },
|
|
15
|
-
router: {
|
|
15
|
+
router: { useHash: false, base: "/", scrollBehavior: "top", templates: "pages", ext: ".tpl" },
|
|
16
16
|
i18n: { defaultLocale: "en", fallbackLocale: "en", detectBrowser: false, loadPath: null, ns: [], cache: true, persist: false },
|
|
17
17
|
debug: false,
|
|
18
18
|
devtools: false,
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
_routerInstance,
|
|
16
16
|
setRouterInstance,
|
|
17
17
|
_log,
|
|
18
|
+
_notifyStoreWatchers,
|
|
18
19
|
} from "./globals.js";
|
|
19
20
|
import { _i18n, _loadI18nForLocale } from "./i18n.js";
|
|
20
21
|
import { createContext } from "./context.js";
|
|
@@ -44,14 +45,20 @@ import "./directives/dnd.js";
|
|
|
44
45
|
// PUBLIC API
|
|
45
46
|
// ═══════════════════════════════════════════════════════════════════════
|
|
46
47
|
|
|
48
|
+
function _stripBase(pathname) {
|
|
49
|
+
const base = (_config.router.base || "/").replace(/\/$/, "");
|
|
50
|
+
if (!base) return pathname || "/";
|
|
51
|
+
const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
52
|
+
return pathname.replace(new RegExp("^" + escaped), "") || "/";
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
function _getDefaultRoutePath() {
|
|
48
56
|
if (typeof window === "undefined") return null;
|
|
49
57
|
const routerCfg = _config.router || {};
|
|
50
|
-
if (routerCfg.
|
|
58
|
+
if (routerCfg.useHash) {
|
|
51
59
|
return window.location.hash.slice(1) || "/";
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
return window.location.pathname.replace(base, "") || "/";
|
|
61
|
+
return _stripBase(window.location.pathname);
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
const NoJS = {
|
|
@@ -82,7 +89,18 @@ const NoJS = {
|
|
|
82
89
|
if (opts.csrf) _config.csrf = opts.csrf;
|
|
83
90
|
if (opts.cache) _config.cache = { ...prevCache, ...opts.cache };
|
|
84
91
|
if (opts.templates) _config.templates = { ...prevTemplates, ...opts.templates };
|
|
85
|
-
if (opts.router)
|
|
92
|
+
if (opts.router) {
|
|
93
|
+
if ("mode" in opts.router && !("useHash" in opts.router)) {
|
|
94
|
+
_log(
|
|
95
|
+
'router.mode is deprecated. Use router.useHash instead: ' +
|
|
96
|
+
'mode: "hash" → useHash: true, mode: "history" → useHash: false',
|
|
97
|
+
"warn"
|
|
98
|
+
);
|
|
99
|
+
opts.router.useHash = opts.router.mode === "hash";
|
|
100
|
+
delete opts.router.mode;
|
|
101
|
+
}
|
|
102
|
+
_config.router = { ...prevRouter, ...opts.router };
|
|
103
|
+
}
|
|
86
104
|
if (opts.i18n) {
|
|
87
105
|
_config.i18n = { ...prevI18n, ...opts.i18n };
|
|
88
106
|
_i18n.locale = opts.i18n.defaultLocale || _i18n.locale;
|
|
@@ -204,6 +222,11 @@ const NoJS = {
|
|
|
204
222
|
return _stores;
|
|
205
223
|
},
|
|
206
224
|
|
|
225
|
+
// Notify global store watchers (for external JS mutations)
|
|
226
|
+
notify() {
|
|
227
|
+
_notifyStoreWatchers();
|
|
228
|
+
},
|
|
229
|
+
|
|
207
230
|
// Access router
|
|
208
231
|
get router() {
|
|
209
232
|
return _routerInstance;
|
|
@@ -217,7 +240,7 @@ const NoJS = {
|
|
|
217
240
|
resolve,
|
|
218
241
|
|
|
219
242
|
// Version
|
|
220
|
-
version: "1.
|
|
243
|
+
version: "1.8.1",
|
|
221
244
|
};
|
|
222
245
|
|
|
223
246
|
export default NoJS;
|
package/src/router.js
CHANGED
|
@@ -10,8 +10,18 @@ import { processTree, _disposeTree } from "./registry.js";
|
|
|
10
10
|
import { _animateIn } from "./animations.js";
|
|
11
11
|
import { _devtoolsEmit } from "./devtools.js";
|
|
12
12
|
|
|
13
|
+
const _BUILTIN_404_HTML = '<div style="text-align:center;padding:3rem 1rem;font-family:system-ui,sans-serif"><h1 style="font-size:4rem;margin:0;opacity:.3">404</h1><p style="font-size:1.25rem;color:#666">Page not found</p></div>';
|
|
14
|
+
|
|
15
|
+
function _stripBase(pathname) {
|
|
16
|
+
const base = (_config.router.base || "/").replace(/\/$/, "");
|
|
17
|
+
if (!base) return pathname || "/";
|
|
18
|
+
const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
return pathname.replace(new RegExp("^" + escaped), "") || "/";
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
export function _createRouter() {
|
|
14
23
|
const routes = [];
|
|
24
|
+
const _wildcards = new Map();
|
|
15
25
|
let current = { path: "", params: {}, query: {}, hash: "" };
|
|
16
26
|
const listeners = new Set();
|
|
17
27
|
const _autoTemplateCache = new Map();
|
|
@@ -68,6 +78,7 @@ export function _createRouter() {
|
|
|
68
78
|
|
|
69
79
|
const matched = matchRoute(cleanPath);
|
|
70
80
|
if (matched) {
|
|
81
|
+
current.matched = true;
|
|
71
82
|
current.params = matched.params;
|
|
72
83
|
|
|
73
84
|
// Guard check
|
|
@@ -85,15 +96,34 @@ export function _createRouter() {
|
|
|
85
96
|
return;
|
|
86
97
|
}
|
|
87
98
|
}
|
|
99
|
+
} else {
|
|
100
|
+
current.matched = false;
|
|
101
|
+
|
|
102
|
+
// Guard check on wildcard template (default outlet)
|
|
103
|
+
const wildcardTpl = _wildcards.get("default");
|
|
104
|
+
if (wildcardTpl) {
|
|
105
|
+
const guardExpr = wildcardTpl.getAttribute("guard");
|
|
106
|
+
const redirectPath = wildcardTpl.getAttribute("redirect");
|
|
107
|
+
if (guardExpr) {
|
|
108
|
+
const ctx = createContext({}, null);
|
|
109
|
+
ctx.__raw.$store = _stores;
|
|
110
|
+
ctx.__raw.$route = current;
|
|
111
|
+
const allowed = evaluate(guardExpr, ctx);
|
|
112
|
+
if (!allowed && redirectPath) {
|
|
113
|
+
await navigate(redirectPath, true);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
// Update URL
|
|
91
|
-
if (_config.router.
|
|
121
|
+
if (_config.router.useHash) {
|
|
92
122
|
const newHash = "#" + path;
|
|
93
123
|
if (replace) window.location.replace(newHash);
|
|
94
124
|
else window.location.hash = path;
|
|
95
125
|
} else {
|
|
96
|
-
const fullPath = _config.router.base.replace(/\/$/, "") + path;
|
|
126
|
+
const fullPath = (_config.router.base || "/").replace(/\/$/, "") + path;
|
|
97
127
|
if (replace) window.history.replaceState({}, "", fullPath);
|
|
98
128
|
else window.history.pushState({}, "", fullPath);
|
|
99
129
|
}
|
|
@@ -157,17 +187,48 @@ export function _createRouter() {
|
|
|
157
187
|
}
|
|
158
188
|
}
|
|
159
189
|
|
|
190
|
+
// ── Wildcard / 404 fallback when no template matched ──
|
|
191
|
+
if (!tpl || tpl.__loadFailed) {
|
|
192
|
+
// Only apply wildcard fallback when no explicit route matched
|
|
193
|
+
// or when a file-based template failed to load.
|
|
194
|
+
// When an explicit route matched but doesn't cover this outlet, just clear it.
|
|
195
|
+
if (!matched || tpl?.__loadFailed) {
|
|
196
|
+
const wildcardTpl = _wildcards.get(outletName)
|
|
197
|
+
|| (outletName !== "default" ? _wildcards.get("default") : null);
|
|
198
|
+
if (wildcardTpl) {
|
|
199
|
+
tpl = wildcardTpl;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
160
204
|
// Always clear first — dispose watchers/listeners before wiping DOM
|
|
161
205
|
_disposeTree(outletEl);
|
|
162
206
|
outletEl.innerHTML = "";
|
|
163
207
|
|
|
164
|
-
if (tpl) {
|
|
208
|
+
if (tpl && !tpl.__loadFailed) {
|
|
165
209
|
// Load template on-demand if not yet fetched
|
|
166
210
|
if (tpl.getAttribute("src") && !tpl.__srcLoaded) {
|
|
167
211
|
_log("Loading route template on demand:", tpl.getAttribute("src"));
|
|
168
212
|
await _loadTemplateElement(tpl);
|
|
169
213
|
}
|
|
170
214
|
|
|
215
|
+
// If template load failed, try wildcard fallback
|
|
216
|
+
if (tpl.__loadFailed) {
|
|
217
|
+
const wildcardTpl = _wildcards.get(outletName)
|
|
218
|
+
|| (outletName !== "default" ? _wildcards.get("default") : null);
|
|
219
|
+
if (wildcardTpl && !wildcardTpl.__loadFailed) {
|
|
220
|
+
tpl = wildcardTpl;
|
|
221
|
+
if (tpl.getAttribute("src") && !tpl.__srcLoaded) {
|
|
222
|
+
await _loadTemplateElement(tpl);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// If still failed (no usable wildcard, or wildcard itself failed), use built-in
|
|
226
|
+
if (!tpl || tpl.__loadFailed) {
|
|
227
|
+
outletEl.innerHTML = _BUILTIN_404_HTML;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
171
232
|
// i18n namespace loading for route template
|
|
172
233
|
const i18nNs = tpl.getAttribute("i18n-ns");
|
|
173
234
|
if (i18nNs) {
|
|
@@ -209,6 +270,9 @@ export function _createRouter() {
|
|
|
209
270
|
|
|
210
271
|
_clearDeclared(wrapper);
|
|
211
272
|
processTree(wrapper);
|
|
273
|
+
} else if (!matched || tpl?.__loadFailed) {
|
|
274
|
+
// No route matched and no wildcard — inject built-in 404
|
|
275
|
+
outletEl.innerHTML = _BUILTIN_404_HTML;
|
|
212
276
|
}
|
|
213
277
|
}
|
|
214
278
|
|
|
@@ -278,7 +342,7 @@ export function _createRouter() {
|
|
|
278
342
|
const backgroundFetches = [];
|
|
279
343
|
|
|
280
344
|
for (const [path, lazy] of routeLazy) {
|
|
281
|
-
if (lazy === "ondemand" || path === current.path) continue;
|
|
345
|
+
if (lazy === "ondemand" || path === current.path || path === "*") continue;
|
|
282
346
|
const segment = path === "/" ? indexName : path.replace(/^\//, "");
|
|
283
347
|
const fullSrc = baseSrc + segment + ext;
|
|
284
348
|
const cacheKey = outletName + ":" + fullSrc;
|
|
@@ -328,6 +392,10 @@ export function _createRouter() {
|
|
|
328
392
|
return () => listeners.delete(fn);
|
|
329
393
|
},
|
|
330
394
|
register(path, templateEl, outlet = "default") {
|
|
395
|
+
if (path === "*") {
|
|
396
|
+
_wildcards.set(outlet, templateEl);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
331
399
|
const entry = _getOrCreateEntry(path);
|
|
332
400
|
entry.outlets[outlet] = templateEl;
|
|
333
401
|
},
|
|
@@ -336,6 +404,10 @@ export function _createRouter() {
|
|
|
336
404
|
document.querySelectorAll("template[route]").forEach((tpl) => {
|
|
337
405
|
const path = tpl.getAttribute("route");
|
|
338
406
|
const outlet = tpl.getAttribute("outlet") || "default";
|
|
407
|
+
if (path === "*") {
|
|
408
|
+
_wildcards.set(outlet, tpl);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
339
411
|
const entry = _getOrCreateEntry(path);
|
|
340
412
|
entry.outlets[outlet] = tpl;
|
|
341
413
|
});
|
|
@@ -350,18 +422,21 @@ export function _createRouter() {
|
|
|
350
422
|
return;
|
|
351
423
|
}
|
|
352
424
|
|
|
353
|
-
//
|
|
354
|
-
// scroll to the target element
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
425
|
+
// Intercept plain anchor links (href="#id") in BOTH modes
|
|
426
|
+
// so they scroll to the target element without triggering
|
|
427
|
+
// route navigation or popstate re-renders.
|
|
428
|
+
const anchor = e.target.closest('a[href^="#"]');
|
|
429
|
+
if (anchor && !anchor.hasAttribute("route")) {
|
|
430
|
+
const href = anchor.getAttribute("href");
|
|
431
|
+
const id = href.slice(1);
|
|
432
|
+
if (id && !id.startsWith("/")) {
|
|
433
|
+
const target = document.getElementById(id);
|
|
434
|
+
if (target) {
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
_scrollToAnchor(id, target);
|
|
437
|
+
// In history mode, update URL hash without triggering popstate
|
|
438
|
+
if (!_config.router.useHash) {
|
|
439
|
+
window.history.replaceState(null, "", "#" + id);
|
|
365
440
|
}
|
|
366
441
|
}
|
|
367
442
|
}
|
|
@@ -369,7 +444,7 @@ export function _createRouter() {
|
|
|
369
444
|
});
|
|
370
445
|
|
|
371
446
|
// Listen for URL changes
|
|
372
|
-
if (_config.router.
|
|
447
|
+
if (_config.router.useHash) {
|
|
373
448
|
window.addEventListener("hashchange", () => {
|
|
374
449
|
const raw = window.location.hash.slice(1) || "/";
|
|
375
450
|
if (!raw.startsWith("/")) {
|
|
@@ -390,12 +465,19 @@ export function _createRouter() {
|
|
|
390
465
|
await navigate(path, true);
|
|
391
466
|
} else {
|
|
392
467
|
window.addEventListener("popstate", () => {
|
|
393
|
-
const path =
|
|
394
|
-
|
|
468
|
+
const path = _stripBase(window.location.pathname);
|
|
469
|
+
// Guard: don't re-navigate if only the hash changed
|
|
470
|
+
if (path === current.path) {
|
|
471
|
+
const hash = window.location.hash.slice(1);
|
|
472
|
+
if (hash) {
|
|
473
|
+
const el = document.getElementById(hash);
|
|
474
|
+
if (el) _scrollToAnchor(hash, el);
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
395
478
|
navigate(path, true);
|
|
396
479
|
});
|
|
397
|
-
const path =
|
|
398
|
-
window.location.pathname.replace(_config.router.base, "") || "/";
|
|
480
|
+
const path = _stripBase(window.location.pathname);
|
|
399
481
|
await navigate(path, true);
|
|
400
482
|
}
|
|
401
483
|
|