@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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/cjs/no.js +25 -0
- package/dist/cjs/no.js.map +7 -0
- package/dist/esm/no.js +25 -0
- package/dist/esm/no.js.map +7 -0
- package/dist/iife/no.js +25 -0
- package/dist/iife/no.js.map +7 -0
- package/package.json +64 -0
- package/src/animations.js +113 -0
- package/src/cdn.js +16 -0
- package/src/context.js +104 -0
- package/src/directives/binding.js +118 -0
- package/src/directives/conditionals.js +283 -0
- package/src/directives/events.js +153 -0
- package/src/directives/http.js +288 -0
- package/src/directives/i18n.js +29 -0
- package/src/directives/loops.js +235 -0
- package/src/directives/refs.js +144 -0
- package/src/directives/state.js +102 -0
- package/src/directives/styling.js +88 -0
- package/src/directives/validation.js +216 -0
- package/src/dom.js +232 -0
- package/src/evaluate.js +298 -0
- package/src/fetch.js +173 -0
- package/src/filters.js +137 -0
- package/src/globals.js +58 -0
- package/src/i18n.js +36 -0
- package/src/index.js +189 -0
- package/src/registry.js +60 -0
- package/src/router.js +253 -0
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
|
+
}
|