@erickxavier/no-js 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/src/router.js ADDED
@@ -0,0 +1,253 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // CLIENT-SIDE ROUTER
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+
5
+ import { _config, _stores, _log } from "./globals.js";
6
+ import { createContext } from "./context.js";
7
+ import { evaluate } from "./evaluate.js";
8
+ import { findContext, _clearDeclared, _loadTemplateElement, _processTemplateIncludes } from "./dom.js";
9
+ import { processTree } from "./registry.js";
10
+ import { _animateIn } from "./animations.js";
11
+
12
+ export function _createRouter() {
13
+ const routes = [];
14
+ let current = { path: "", params: {}, query: {}, hash: "" };
15
+ const listeners = new Set();
16
+
17
+ function _getOrCreateEntry(path) {
18
+ let entry = routes.find((r) => r.path === path);
19
+ if (!entry) {
20
+ entry = { path, outlets: {} };
21
+ routes.push(entry);
22
+ }
23
+ return entry;
24
+ }
25
+
26
+ function parseQuery(search) {
27
+ const params = {};
28
+ new URLSearchParams(search).forEach((v, k) => {
29
+ params[k] = v;
30
+ });
31
+ return params;
32
+ }
33
+
34
+ function matchRoute(path) {
35
+ for (const route of routes) {
36
+ const paramNames = [];
37
+ const pattern = route.path.replace(/:(\w+)/g, (_, name) => {
38
+ paramNames.push(name);
39
+ return "([^/]+)";
40
+ });
41
+ const regex = new RegExp("^" + pattern + "$");
42
+ const match = path.match(regex);
43
+ if (match) {
44
+ const params = {};
45
+ paramNames.forEach((name, i) => {
46
+ params[name] = match[i + 1];
47
+ });
48
+ return { route, params };
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ async function navigate(path, replace = false) {
55
+ const hashIdx = path.indexOf("#");
56
+ const hash = hashIdx >= 0 ? path.slice(hashIdx + 1) : "";
57
+ const withoutHash = hashIdx >= 0 ? path.slice(0, hashIdx) : path;
58
+ const [cleanPath, search = ""] = withoutHash.split("?");
59
+
60
+ current = {
61
+ path: cleanPath,
62
+ params: {},
63
+ query: parseQuery(search),
64
+ hash: hash ? "#" + hash : "",
65
+ };
66
+
67
+ const matched = matchRoute(cleanPath);
68
+ if (matched) {
69
+ current.params = matched.params;
70
+
71
+ // Guard check
72
+ const tpl = matched.route.outlets?.["default"];
73
+ const guardExpr = tpl?.getAttribute("guard");
74
+ const redirectPath = tpl?.getAttribute("redirect");
75
+
76
+ if (guardExpr) {
77
+ const ctx = createContext({}, null);
78
+ ctx.__raw.$store = _stores;
79
+ ctx.__raw.$route = current;
80
+ const allowed = evaluate(guardExpr, ctx);
81
+ if (!allowed && redirectPath) {
82
+ await navigate(redirectPath, true);
83
+ return;
84
+ }
85
+ }
86
+ }
87
+
88
+ // Update URL
89
+ if (_config.router.mode === "hash") {
90
+ const newHash = "#" + path;
91
+ if (replace) window.location.replace(newHash);
92
+ else window.location.hash = path;
93
+ } else {
94
+ const fullPath = _config.router.base.replace(/\/$/, "") + path;
95
+ if (replace) window.history.replaceState({}, "", fullPath);
96
+ else window.history.pushState({}, "", fullPath);
97
+ }
98
+
99
+ // Render
100
+ await _renderRoute(matched);
101
+ listeners.forEach((fn) => fn(current));
102
+ }
103
+
104
+ async function _renderRoute(matched) {
105
+ const outletEls = document.querySelectorAll("[route-view]");
106
+ for (const outletEl of outletEls) {
107
+ // Determine outlet name ("" or missing attribute value → "default")
108
+ const outletAttr = outletEl.getAttribute("route-view");
109
+ const outletName = outletAttr && outletAttr.trim() !== "" ? outletAttr.trim() : "default";
110
+
111
+ // Find the template for this outlet in the matched route
112
+ const tpl = matched?.route?.outlets?.[outletName];
113
+
114
+ // Always clear first
115
+ outletEl.innerHTML = "";
116
+
117
+ if (tpl) {
118
+ // Load template on-demand if not yet fetched
119
+ if (tpl.getAttribute("src") && !tpl.__srcLoaded) {
120
+ _log("Loading route template on demand:", tpl.getAttribute("src"));
121
+ await _loadTemplateElement(tpl);
122
+ }
123
+
124
+ const clone = tpl.content.cloneNode(true);
125
+
126
+ const routeCtx = createContext(
127
+ { $route: current },
128
+ findContext(outletEl),
129
+ );
130
+ const wrapper = document.createElement("div");
131
+ wrapper.style.display = "contents";
132
+ wrapper.__ctx = routeCtx;
133
+ // Preserve __srcBase so nested ./relative template paths resolve correctly.
134
+ // cloneNode() copies DOM nodes but NOT custom JS properties, so we must
135
+ // re-stamp it on the wrapper element (which IS in the ancestor chain).
136
+ if (tpl.content.__srcBase) wrapper.__srcBase = tpl.content.__srcBase;
137
+ wrapper.appendChild(clone);
138
+ // Insert into live DOM first so nested template loading has DOM access
139
+ // (required for loading="#id" skeleton placeholder lookups via getElementById)
140
+ outletEl.appendChild(wrapper);
141
+
142
+ // Process inline template includes synchronously (e.g. template[include])
143
+ _processTemplateIncludes(wrapper);
144
+ // Load each nested template individually via _loadTemplateElement so that:
145
+ // 1. The loading="#id" skeleton placeholder attribute is honoured
146
+ // 2. getElementById can resolve the skeleton (wrapper is already in live DOM)
147
+ // 3. No batch-recursion on a live DOM node (avoids re-entrant fetch loops)
148
+ const nestedTpls = [...wrapper.querySelectorAll("template[src]")];
149
+ _log("[ROUTER] nested templates found in wrapper:", nestedTpls.length, nestedTpls.map(t => t.getAttribute("src") + (t.__srcLoaded ? "[LOADED]" : "[NEW]")));
150
+ await Promise.all(nestedTpls.map(_loadTemplateElement));
151
+ _log("[ROUTER] all nested loads done for route:", current.path);
152
+
153
+ const transition = outletEl.getAttribute("transition");
154
+ if (transition) _animateIn(wrapper, null, transition);
155
+
156
+ _clearDeclared(wrapper);
157
+ processTree(wrapper);
158
+ }
159
+ }
160
+
161
+ // Update active classes
162
+ document.querySelectorAll("[route]").forEach((link) => {
163
+ const routePath = link.getAttribute("route");
164
+ const activeClass = link.getAttribute("route-active") || "active";
165
+ const exactClass = link.getAttribute("route-active-exact");
166
+
167
+ if (exactClass) {
168
+ link.classList.toggle(exactClass, current.path === routePath);
169
+ } else if (activeClass && !link.hasAttribute("route-active-exact")) {
170
+ link.classList.toggle(
171
+ activeClass,
172
+ current.path.startsWith(routePath),
173
+ );
174
+ }
175
+ });
176
+
177
+ // Scroll behavior
178
+ const scrollBehavior = _config.router.scrollBehavior;
179
+ if (scrollBehavior === "top") {
180
+ window.scrollTo(0, 0);
181
+ } else if (scrollBehavior === "smooth") {
182
+ window.scrollTo({ top: 0, behavior: "smooth" });
183
+ }
184
+ // "preserve" — do nothing, keep current scroll position
185
+ }
186
+
187
+ const router = {
188
+ get current() {
189
+ return current;
190
+ },
191
+ push(path) {
192
+ return navigate(path);
193
+ },
194
+ replace(path) {
195
+ return navigate(path, true);
196
+ },
197
+ back() {
198
+ window.history.back();
199
+ },
200
+ forward() {
201
+ window.history.forward();
202
+ },
203
+ on(fn) {
204
+ listeners.add(fn);
205
+ return () => listeners.delete(fn);
206
+ },
207
+ register(path, templateEl, outlet = "default") {
208
+ const entry = _getOrCreateEntry(path);
209
+ entry.outlets[outlet] = templateEl;
210
+ },
211
+ async init() {
212
+ // Collect route templates
213
+ document.querySelectorAll("template[route]").forEach((tpl) => {
214
+ const path = tpl.getAttribute("route");
215
+ const outlet = tpl.getAttribute("outlet") || "default";
216
+ const entry = _getOrCreateEntry(path);
217
+ entry.outlets[outlet] = tpl;
218
+ });
219
+
220
+ // Bind route links
221
+ document.addEventListener("click", (e) => {
222
+ const link = e.target.closest("[route]");
223
+ if (link && !link.hasAttribute("route-view")) {
224
+ e.preventDefault();
225
+ const path = link.getAttribute("route");
226
+ navigate(path);
227
+ }
228
+ });
229
+
230
+ // Listen for URL changes
231
+ if (_config.router.mode === "hash") {
232
+ window.addEventListener("hashchange", () => {
233
+ const path = window.location.hash.slice(1) || "/";
234
+ navigate(path, true);
235
+ });
236
+ // Initial route
237
+ const path = window.location.hash.slice(1) || "/";
238
+ await navigate(path, true);
239
+ } else {
240
+ window.addEventListener("popstate", () => {
241
+ const path =
242
+ window.location.pathname.replace(_config.router.base, "") || "/";
243
+ navigate(path, true);
244
+ });
245
+ const path =
246
+ window.location.pathname.replace(_config.router.base, "") || "/";
247
+ await navigate(path, true);
248
+ }
249
+ },
250
+ };
251
+
252
+ return router;
253
+ }