@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "The HTML-first reactive framework — build dynamic web apps with just HTML attributes, no JavaScript required",
5
5
  "main": "dist/cjs/no.js",
6
6
  "module": "dist/esm/no.js",
@@ -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
- const data = await _doFetch(resolvedUrl, method, reqBody, {}, el);
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 (asKey && intoStore) {
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 non-pristine fields
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) => r.text()).then((h) => {
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: { mode: "history", base: "/", scrollBehavior: "top", templates: "pages", ext: ".tpl" },
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.mode === "hash") {
58
+ if (routerCfg.useHash) {
51
59
  return window.location.hash.slice(1) || "/";
52
60
  }
53
- const base = (routerCfg.base || "/").replace(/\/$/, "");
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) _config.router = { ...prevRouter, ...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.7.0",
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.mode === "hash") {
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
- // In hash mode, intercept plain anchor links (href="#id") so they
354
- // scroll to the target element instead of conflicting with the router.
355
- if (_config.router.mode === "hash") {
356
- const anchor = e.target.closest('a[href^="#"]');
357
- if (anchor && !anchor.hasAttribute("route")) {
358
- const href = anchor.getAttribute("href");
359
- const id = href.slice(1);
360
- if (id && !id.startsWith("/")) {
361
- const target = document.getElementById(id);
362
- if (target) {
363
- e.preventDefault();
364
- _scrollToAnchor(id, target);
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.mode === "hash") {
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
- window.location.pathname.replace(_config.router.base, "") || "/";
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