@erickxavier/no-js 1.0.2 → 1.2.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.
@@ -5,6 +5,7 @@
5
5
  import { evaluate, _execStatement } from "../evaluate.js";
6
6
  import { findContext } from "../dom.js";
7
7
  import { registerDirective } from "../registry.js";
8
+ import { _onDispose } from "../globals.js";
8
9
 
9
10
  registerDirective("on:*", {
10
11
  priority: 20,
@@ -28,14 +29,17 @@ registerDirective("on:*", {
28
29
  _execStatement(expr, ctx, { $el: el });
29
30
  });
30
31
  updatedObserver.observe(el, { childList: true, subtree: true, characterData: true, attributes: true });
32
+ _onDispose(() => updatedObserver.disconnect());
31
33
  return;
32
34
  }
33
35
  if (event === "error") {
34
- window.addEventListener("error", (e) => {
36
+ const errorHandler = (e) => {
35
37
  if (el.contains(e.target) || e.target === el) {
36
38
  _execStatement(expr, ctx, { $el: el, $error: e.error || e.message });
37
39
  }
38
- });
40
+ };
41
+ window.addEventListener("error", errorHandler);
42
+ _onDispose(() => window.removeEventListener("error", errorHandler));
39
43
  return;
40
44
  }
41
45
  if (event === "unmounted") {
@@ -51,7 +55,8 @@ registerDirective("on:*", {
51
55
  }
52
56
  });
53
57
  if (el.parentElement)
54
- observer.observe(el.parentElement, { childList: true });
58
+ observer.observe(el.parentElement, { childList: true, subtree: true });
59
+ _onDispose(() => observer.disconnect());
55
60
  return;
56
61
  }
57
62
 
@@ -4,12 +4,12 @@
4
4
 
5
5
  import {
6
6
  _config,
7
- _log,
8
7
  _warn,
9
8
  _stores,
10
9
  _notifyStoreWatchers,
11
10
  _emitEvent,
12
11
  _routerInstance,
12
+ _onDispose,
13
13
  } from "../globals.js";
14
14
  import { createContext } from "../context.js";
15
15
  import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
@@ -123,16 +123,20 @@ for (const method of HTTP_METHODS) {
123
123
  const savedRetryDelay = _config.retryDelay;
124
124
  _config.retries = retryCount;
125
125
  _config.retryDelay = retryDelay;
126
- const data = await _doFetch(
127
- resolvedUrl,
128
- method,
129
- reqBody,
130
- extraHeaders,
131
- el,
132
- _activeAbort.signal,
133
- );
134
- _config.retries = savedRetries;
135
- _config.retryDelay = savedRetryDelay;
126
+ let data;
127
+ try {
128
+ data = await _doFetch(
129
+ resolvedUrl,
130
+ method,
131
+ reqBody,
132
+ extraHeaders,
133
+ el,
134
+ _activeAbort.signal,
135
+ );
136
+ } finally {
137
+ _config.retries = savedRetries;
138
+ _config.retryDelay = savedRetryDelay;
139
+ }
136
140
 
137
141
  // Cache response
138
142
  if (method === "get") _cacheSet(cacheKey, data, cacheStrategy);
@@ -281,7 +285,8 @@ for (const method of HTTP_METHODS) {
281
285
 
282
286
  // Polling
283
287
  if (refreshInterval > 0) {
284
- setInterval(doRequest, refreshInterval);
288
+ const id = setInterval(doRequest, refreshInterval);
289
+ _onDispose(() => clearInterval(id));
285
290
  }
286
291
  },
287
292
  });
@@ -1,30 +1,56 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════
2
2
  // DIRECTIVE: t (i18n translations)
3
+ // DIRECTIVE: i18n-ns (load namespace before children)
3
4
  // ═══════════════════════════════════════════════════════════════════════
4
5
 
5
- import { _i18n, _watchI18n } from "../i18n.js";
6
+ import { _i18n, _watchI18n, _loadI18nNamespace, _notifyI18n } from "../i18n.js";
7
+ import { _watchExpr } from "../globals.js";
6
8
  import { evaluate } from "../evaluate.js";
7
9
  import { findContext } from "../dom.js";
8
- import { registerDirective } from "../registry.js";
10
+ import { registerDirective, processTree } from "../registry.js";
9
11
 
10
12
  registerDirective("t", {
11
13
  priority: 20,
12
14
  init(el, name, key) {
13
15
  const ctx = findContext(el);
16
+ const useHtml = el.hasAttribute("t-html");
14
17
 
15
18
  function update() {
16
19
  const params = {};
17
20
  for (const attr of [...el.attributes]) {
18
- if (attr.name.startsWith("t-") && attr.name !== "t") {
21
+ if (attr.name.startsWith("t-") && attr.name !== "t" && attr.name !== "t-html") {
19
22
  const paramName = attr.name.replace("t-", "");
20
23
  params[paramName] = evaluate(attr.value, ctx) ?? attr.value;
21
24
  }
22
25
  }
23
- el.textContent = _i18n.t(key, params);
26
+ const text = _i18n.t(key, params);
27
+ if (useHtml) {
28
+ el.innerHTML = text;
29
+ } else {
30
+ el.textContent = text;
31
+ }
24
32
  }
25
33
 
26
- ctx.$watch(update);
34
+ _watchExpr(key, ctx, update);
27
35
  _watchI18n(update);
28
36
  update();
29
37
  },
30
38
  });
39
+
40
+ registerDirective("i18n-ns", {
41
+ priority: 1,
42
+ init(el, name, ns) {
43
+ // Empty ns = marker attribute (e.g. route-view); skip loading
44
+ if (!ns) return;
45
+
46
+ // Save children to prevent premature t resolution
47
+ const saved = document.createDocumentFragment();
48
+ while (el.firstChild) saved.appendChild(el.firstChild);
49
+
50
+ _loadI18nNamespace(ns).then(() => {
51
+ el.appendChild(saved);
52
+ processTree(el);
53
+ _notifyI18n();
54
+ });
55
+ },
56
+ });
@@ -3,6 +3,7 @@
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
5
  import { createContext } from "../context.js";
6
+ import { _watchExpr } from "../globals.js";
6
7
  import { evaluate, resolve } from "../evaluate.js";
7
8
  import { findContext, _cloneTemplate } from "../dom.js";
8
9
  import { registerDirective, processTree } from "../registry.js";
@@ -23,11 +24,26 @@ registerDirective("each", {
23
24
  const animLeave = el.getAttribute("animate-leave");
24
25
  const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
25
26
  const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
27
+ let prevList = null;
26
28
 
27
29
  function update() {
28
- let list = resolve(listPath, ctx);
30
+ let list = /[\[\]()\s+\-*\/!?:&|]/.test(listPath)
31
+ ? evaluate(listPath, ctx)
32
+ : resolve(listPath, ctx);
29
33
  if (!Array.isArray(list)) return;
30
34
 
35
+ // If same list reference and items are rendered, skip re-render
36
+ // and just propagate the notification to child contexts so their
37
+ // watchers (bind, show, model, etc.) can react to parent changes
38
+ // without destroying/recreating the DOM (preserves input focus).
39
+ if (list === prevList && list.length > 0 && el.children.length > 0) {
40
+ for (const child of el.children) {
41
+ if (child.__ctx && child.__ctx.$notify) child.__ctx.$notify();
42
+ }
43
+ return;
44
+ }
45
+ prevList = list;
46
+
31
47
  // Empty state
32
48
  if (list.length === 0 && elseTpl) {
33
49
  const clone = _cloneTemplate(elseTpl);
@@ -90,6 +106,7 @@ registerDirective("each", {
90
106
  const firstChild = wrapper.firstElementChild;
91
107
  if (firstChild) {
92
108
  firstChild.classList.add(animEnter);
109
+ firstChild.addEventListener("animationend", () => firstChild.classList.remove(animEnter), { once: true });
93
110
  // Stagger animation — delay must be on the child, not the wrapper
94
111
  if (stagger) {
95
112
  firstChild.style.animationDelay = i * stagger + "ms";
@@ -99,7 +116,7 @@ registerDirective("each", {
99
116
  });
100
117
  }
101
118
 
102
- ctx.$watch(update);
119
+ _watchExpr(expr, ctx, update);
103
120
  update();
104
121
  },
105
122
  });
@@ -204,6 +221,7 @@ registerDirective("foreach", {
204
221
  const firstChild = wrapper.firstElementChild;
205
222
  if (firstChild) {
206
223
  firstChild.classList.add(animEnter);
224
+ firstChild.addEventListener("animationend", () => firstChild.classList.remove(animEnter), { once: true });
207
225
  // Stagger animation — delay must be on the child, not the wrapper
208
226
  if (stagger) {
209
227
  firstChild.style.animationDelay = (i * stagger) + "ms";
@@ -233,7 +251,7 @@ registerDirective("foreach", {
233
251
  }
234
252
  }
235
253
 
236
- ctx.$watch(update);
254
+ _watchExpr(fromPath, ctx, update);
237
255
  update();
238
256
  },
239
257
  });
@@ -6,6 +6,7 @@ import {
6
6
  _refs,
7
7
  _stores,
8
8
  _notifyStoreWatchers,
9
+ _onDispose,
9
10
  } from "../globals.js";
10
11
  import { createContext } from "../context.js";
11
12
  import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
@@ -17,6 +18,9 @@ registerDirective("ref", {
17
18
  priority: 5,
18
19
  init(el, name, refName) {
19
20
  _refs[refName] = el;
21
+ _onDispose(() => {
22
+ if (_refs[refName] === el) delete _refs[refName];
23
+ });
20
24
  },
21
25
  });
22
26
 
@@ -2,7 +2,7 @@
2
2
  // DIRECTIVES: state, store, computed, watch
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _stores, _log } from "../globals.js";
5
+ import { _stores, _log, _watchExpr } from "../globals.js";
6
6
  import { createContext } from "../context.js";
7
7
  import { evaluate, _execStatement } from "../evaluate.js";
8
8
  import { findContext } from "../dom.js";
@@ -78,7 +78,7 @@ registerDirective("computed", {
78
78
  const val = evaluate(expr, ctx);
79
79
  ctx.$set(computedName, val);
80
80
  }
81
- ctx.$watch(update);
81
+ _watchExpr(expr, ctx, update);
82
82
  update();
83
83
  },
84
84
  });
@@ -89,7 +89,7 @@ registerDirective("watch", {
89
89
  const ctx = findContext(el);
90
90
  const onChange = el.getAttribute("on:change");
91
91
  let lastVal = evaluate(watchExpr, ctx);
92
- ctx.$watch(() => {
92
+ _watchExpr(watchExpr, ctx, () => {
93
93
  const newVal = evaluate(watchExpr, ctx);
94
94
  if (newVal !== lastVal) {
95
95
  const oldVal = lastVal;
@@ -2,9 +2,11 @@
2
2
  // DIRECTIVES: class-*, style-*
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
+ import { _watchExpr } from "../globals.js";
5
6
  import { evaluate } from "../evaluate.js";
6
7
  import { findContext } from "../dom.js";
7
8
  import { registerDirective } from "../registry.js";
9
+ import { _watchI18n } from "../i18n.js";
8
10
 
9
11
  registerDirective("class-*", {
10
12
  priority: 20,
@@ -13,16 +15,18 @@ registerDirective("class-*", {
13
15
  const ctx = findContext(el);
14
16
 
15
17
  // class-map="{ active: x, bold: y }"
18
+ // Supports space-separated keys: class-map="{ 'bg-sky-500 text-white': x }"
16
19
  if (suffix === "map") {
17
20
  function update() {
18
21
  const obj = evaluate(expr, ctx);
19
22
  if (obj && typeof obj === "object") {
20
23
  for (const [cls, cond] of Object.entries(obj)) {
21
- el.classList.toggle(cls, !!cond);
24
+ const parts = cls.split(/\s+/).filter(Boolean);
25
+ parts.forEach((c) => el.classList.toggle(c, !!cond));
22
26
  }
23
27
  }
24
28
  }
25
- ctx.$watch(update);
29
+ _watchExpr(expr, ctx, update);
26
30
  update();
27
31
  return;
28
32
  }
@@ -41,7 +45,7 @@ registerDirective("class-*", {
41
45
  prevClasses = next;
42
46
  }
43
47
  }
44
- ctx.$watch(update);
48
+ _watchExpr(expr, ctx, update);
45
49
  update();
46
50
  return;
47
51
  }
@@ -50,7 +54,8 @@ registerDirective("class-*", {
50
54
  function update() {
51
55
  el.classList.toggle(suffix, !!evaluate(expr, ctx));
52
56
  }
53
- ctx.$watch(update);
57
+ _watchExpr(expr, ctx, update);
58
+ if (expr.includes("$i18n") || expr.includes("NoJS.locale")) _watchI18n(update);
54
59
  update();
55
60
  },
56
61
  });
@@ -71,7 +76,7 @@ registerDirective("style-*", {
71
76
  }
72
77
  }
73
78
  }
74
- ctx.$watch(update);
79
+ _watchExpr(expr, ctx, update);
75
80
  update();
76
81
  return;
77
82
  }
@@ -82,7 +87,7 @@ registerDirective("style-*", {
82
87
  const val = evaluate(expr, ctx);
83
88
  el.style[cssProp] = val != null ? String(val) : "";
84
89
  }
85
- ctx.$watch(update);
90
+ _watchExpr(expr, ctx, update);
86
91
  update();
87
92
  },
88
93
  });
@@ -3,7 +3,7 @@
3
3
  // HELPER: _validateField
4
4
  // ═══════════════════════════════════════════════════════════════════════
5
5
 
6
- import { _validators } from "../globals.js";
6
+ import { _validators, _onDispose } from "../globals.js";
7
7
  import { createContext } from "../context.js";
8
8
  import { findContext, _cloneTemplate } from "../dom.js";
9
9
  import { registerDirective, processTree } from "../registry.js";
@@ -223,10 +223,12 @@ registerDirective("error-boundary", {
223
223
  });
224
224
 
225
225
  // Listen for window-level errors (resource load failures, etc.)
226
- window.addEventListener("error", (e) => {
226
+ const errorHandler = (e) => {
227
227
  if (el.contains(e.target) || el === e.target) {
228
228
  showFallback(e.message || "An error occurred");
229
229
  }
230
- });
230
+ };
231
+ window.addEventListener("error", errorHandler);
232
+ _onDispose(() => window.removeEventListener("error", errorHandler));
231
233
  },
232
234
  });
package/src/evaluate.js CHANGED
@@ -265,22 +265,28 @@ export function _execStatement(expr, ctx, extraVars = {}) {
265
265
  // For each key in any ancestor context, find the owning context at runtime
266
266
  // and call $set on it — so mutations inside `each` loops correctly
267
267
  // propagate back to parent state (e.g. cart updated from a loop's on:click).
268
+ // Only write back values that actually changed locally, to avoid
269
+ // overwriting proxy mutations made by called functions.
268
270
  const chainKeys = new Set();
269
271
  let _wCtx = ctx;
270
272
  while (_wCtx && _wCtx.__isProxy) {
271
273
  for (const k of Object.keys(_wCtx.__raw)) chainKeys.add(k);
272
274
  _wCtx = _wCtx.$parent;
273
275
  }
276
+ const origObj = {};
277
+ for (const k of chainKeys) {
278
+ if (!k.startsWith("$") && k in vals) origObj[k] = vals[k];
279
+ }
274
280
  const setters = [...chainKeys]
275
281
  .filter((k) => !k.startsWith("$"))
276
282
  .map(
277
283
  (k) =>
278
- `{let _c=__ctx;while(_c&&_c.__isProxy){if('${k}'in _c.__raw){_c.$set('${k}',typeof ${k}!=='undefined'?${k}:_c.__raw['${k}']);break;}_c=_c.$parent;}}`,
284
+ `{let _c=__ctx;while(_c&&_c.__isProxy){if('${k}'in _c.__raw){if(typeof ${k}!=='undefined'){if(${k}!==__orig['${k}'])_c.$set('${k}',${k});else if(typeof ${k}==='object'&&${k}!==null)_c.$notify();}break;}_c=_c.$parent;}}`,
279
285
  )
280
286
  .join("\n");
281
287
 
282
- const fn = new Function("__ctx", ...keyArr, `${expr};\n${setters}`);
283
- fn(ctx, ...valArr);
288
+ const fn = new Function("__ctx", "__orig", ...keyArr, `${expr};\n${setters}`);
289
+ fn(ctx, origObj, ...valArr);
284
290
 
285
291
  // Notify global store watchers when expression touches $store
286
292
  if (typeof expr === "string" && expr.includes("$store")) {
package/src/globals.js CHANGED
@@ -12,8 +12,8 @@ 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" },
16
- i18n: { defaultLocale: "en", fallbackLocale: "en", detectBrowser: false },
15
+ router: { mode: "history", base: "/", scrollBehavior: "top", templates: "pages", ext: ".tpl" },
16
+ i18n: { defaultLocale: "en", fallbackLocale: "en", detectBrowser: false, loadPath: null, ns: [], cache: true, persist: false },
17
17
  debug: false,
18
18
  devtools: false,
19
19
  csp: null,
@@ -30,6 +30,15 @@ export const _cache = new Map();
30
30
  export const _refs = {};
31
31
  export let _routerInstance = null;
32
32
 
33
+ // ─── Lifecycle: tracks the element being processed by processElement ────────
34
+ // Used by ctx.$watch and _onDispose to transparently tag watchers/disposers
35
+ // with the owning DOM element — no changes needed in directive files.
36
+ export let _currentEl = null;
37
+
38
+ export function _setCurrentEl(el) {
39
+ _currentEl = el;
40
+ }
41
+
33
42
  export function setRouterInstance(r) {
34
43
  _routerInstance = r;
35
44
  }
@@ -43,7 +52,13 @@ export function _warn(...args) {
43
52
  }
44
53
 
45
54
  export function _notifyStoreWatchers() {
46
- for (const fn of _storeWatchers) fn();
55
+ for (const fn of _storeWatchers) {
56
+ if (fn._el && !fn._el.isConnected) {
57
+ _storeWatchers.delete(fn);
58
+ continue;
59
+ }
60
+ fn();
61
+ }
47
62
  }
48
63
 
49
64
  export function _watchExpr(expr, ctx, fn) {
@@ -53,6 +68,15 @@ export function _watchExpr(expr, ctx, fn) {
53
68
  }
54
69
  }
55
70
 
71
+ // Register a dispose callback on the element currently being processed.
72
+ // Called from directives to clean up intervals, observers, window listeners.
73
+ export function _onDispose(fn) {
74
+ if (_currentEl) {
75
+ _currentEl.__disposers = _currentEl.__disposers || [];
76
+ _currentEl.__disposers.push(fn);
77
+ }
78
+ }
79
+
56
80
  export function _emitEvent(name, data) {
57
81
  (_eventBus[name] || []).forEach((fn) => fn(data));
58
82
  }
package/src/i18n.js CHANGED
@@ -2,15 +2,84 @@
2
2
  // i18n SYSTEM
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _config } from "./globals.js";
5
+ import { _config, _warn } from "./globals.js";
6
6
 
7
7
  const _i18nListeners = new Set();
8
+ export { _i18nListeners };
8
9
 
9
10
  export function _watchI18n(fn) {
10
11
  _i18nListeners.add(fn);
11
12
  return () => _i18nListeners.delete(fn);
12
13
  }
13
14
 
15
+ // ─── Notify all i18n listeners (shared by setter + directive) ────────
16
+ export function _notifyI18n() {
17
+ for (const fn of _i18nListeners) {
18
+ if (fn._el && !fn._el.isConnected) { _i18nListeners.delete(fn); continue; }
19
+ fn();
20
+ }
21
+ }
22
+
23
+ // ─── Deep merge (recursive, returns new object) ─────────────────────
24
+ export function _deepMerge(target, source) {
25
+ const out = { ...target };
26
+ for (const key of Object.keys(source)) {
27
+ if (
28
+ source[key] && typeof source[key] === "object" && !Array.isArray(source[key]) &&
29
+ target[key] && typeof target[key] === "object" && !Array.isArray(target[key])
30
+ ) {
31
+ out[key] = _deepMerge(target[key], source[key]);
32
+ } else {
33
+ out[key] = source[key];
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // ─── Locale file cache: Map<string, object> key = "en" or "en:dashboard"
40
+ export const _i18nCache = new Map();
41
+ export const _loadedNs = new Set();
42
+
43
+ // ─── Fetch a single JSON file and merge into _i18n.locales[locale] ──
44
+ export async function _loadLocale(locale, ns) {
45
+ const cacheKey = ns ? `${locale}:${ns}` : locale;
46
+ if (_config.i18n.cache && _i18nCache.has(cacheKey)) return;
47
+
48
+ let url = _config.i18n.loadPath.replace("{locale}", locale);
49
+ if (ns) url = url.replace("{ns}", ns);
50
+ else if (url.includes("{ns}")) return; // no namespace to substitute
51
+
52
+
53
+ try {
54
+ const res = await fetch(url);
55
+ if (!res.ok) { _warn(`i18n: failed to load ${url} (${res.status})`); return; }
56
+ const data = await res.json();
57
+ _i18n.locales[locale] = _deepMerge(_i18n.locales[locale] || {}, data);
58
+ if (_config.i18n.cache) _i18nCache.set(cacheKey, data);
59
+ } catch (e) {
60
+ _warn(`i18n: error loading ${url}`, e);
61
+ }
62
+ }
63
+
64
+ // ─── Load all configured data for a locale (flat or all namespaces) ──
65
+ export async function _loadI18nForLocale(locale) {
66
+ if (!_config.i18n.loadPath) return;
67
+ const ns = _config.i18n.ns;
68
+ if (!ns.length || !_config.i18n.loadPath.includes("{ns}")) {
69
+ await _loadLocale(locale, null);
70
+ } else {
71
+ await Promise.all(ns.map((n) => _loadLocale(locale, n)));
72
+ }
73
+ }
74
+
75
+ // ─── Load a single namespace for current + fallback locales ──────────
76
+ export async function _loadI18nNamespace(ns) {
77
+ if (!_config.i18n.loadPath) return;
78
+ _loadedNs.add(ns);
79
+ const locales = new Set([_i18n.locale, _config.i18n.fallbackLocale]);
80
+ await Promise.all([...locales].map((l) => _loadLocale(l, ns)));
81
+ }
82
+
14
83
  export const _i18n = {
15
84
  _locale: "en",
16
85
  locales: {},
@@ -20,7 +89,16 @@ export const _i18n = {
20
89
  set locale(v) {
21
90
  if (this._locale !== v) {
22
91
  this._locale = v;
23
- _i18nListeners.forEach((fn) => fn());
92
+ if (_config.i18n.persist && typeof localStorage !== "undefined") {
93
+ try { localStorage.setItem("nojs-locale", v); } catch (_) {}
94
+ }
95
+ if (_config.i18n.loadPath) {
96
+ // Load configured ns + any route-loaded ns for the new locale
97
+ const allNs = new Set([..._config.i18n.ns, ..._loadedNs]);
98
+ Promise.all([...allNs].map((n) => _loadLocale(v, n))).then(() => _notifyI18n());
99
+ } else {
100
+ _notifyI18n();
101
+ }
24
102
  }
25
103
  },
26
104
  t(key, params = {}) {
package/src/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  setRouterInstance,
17
17
  _log,
18
18
  } from "./globals.js";
19
- import { _i18n } from "./i18n.js";
19
+ import { _i18n, _loadI18nForLocale } from "./i18n.js";
20
20
  import { createContext } from "./context.js";
21
21
  import { evaluate, resolve } from "./evaluate.js";
22
22
  import { findContext, _loadRemoteTemplates, _loadRemoteTemplatesPhase1, _loadRemoteTemplatesPhase2, _processTemplateIncludes } from "./dom.js";
@@ -37,6 +37,7 @@ import "./directives/events.js";
37
37
  import "./directives/refs.js";
38
38
  import "./directives/validation.js";
39
39
  import "./directives/i18n.js";
40
+ import "./directives/dnd.js";
40
41
 
41
42
  // ═══════════════════════════════════════════════════════════════════════
42
43
  // PUBLIC API
@@ -60,6 +61,13 @@ const NoJS = {
60
61
  _config.baseApiUrl = v;
61
62
  },
62
63
 
64
+ get locale() {
65
+ return _i18n.locale;
66
+ },
67
+ set locale(v) {
68
+ _i18n.locale = v;
69
+ },
70
+
63
71
  config(opts = {}) {
64
72
  // Save nested objects before shallow assign overwrites them
65
73
  const prevHeaders = { ..._config.headers };
@@ -85,6 +93,12 @@ const NoJS = {
85
93
  root = root || document.body;
86
94
  _log("Initializing...");
87
95
 
96
+ // Load external locale files (blocking — translations must be available for first paint)
97
+ if (_config.i18n.loadPath) {
98
+ const locales = new Set([_i18n.locale, _config.i18n.fallbackLocale]);
99
+ await Promise.all([...locales].map((l) => _loadI18nForLocale(l)));
100
+ }
101
+
88
102
  // Inline template includes (e.g. skeletons) — synchronous, before any fetch
89
103
  _processTemplateIncludes(root);
90
104
 
@@ -141,13 +155,32 @@ const NoJS = {
141
155
 
142
156
  // i18n
143
157
  i18n(opts) {
158
+ // Set config options BEFORE locale (setter checks loadPath)
159
+ if (opts.loadPath != null) _config.i18n.loadPath = opts.loadPath;
160
+ if (opts.ns) _config.i18n.ns = opts.ns;
161
+ if (opts.cache != null) _config.i18n.cache = opts.cache;
162
+ if (opts.persist != null) _config.i18n.persist = opts.persist;
144
163
  if (opts.locales) _i18n.locales = opts.locales;
145
- if (opts.defaultLocale) _i18n.locale = opts.defaultLocale;
146
164
  if (opts.fallbackLocale) _config.i18n.fallbackLocale = opts.fallbackLocale;
165
+
166
+ // Set defaultLocale WITHOUT the setter (avoids overwriting localStorage)
167
+ if (opts.defaultLocale) _i18n._locale = opts.defaultLocale;
168
+
169
+ // Restore persisted locale (highest priority)
170
+ if (_config.i18n.persist && typeof localStorage !== "undefined") {
171
+ try {
172
+ const saved = localStorage.getItem("nojs-locale");
173
+ if (saved && _i18n.locales[saved]) { _i18n._locale = saved; return; }
174
+ } catch (_) {}
175
+ }
176
+
177
+ // Detect browser language (second priority)
147
178
  if (opts.detectBrowser) {
148
179
  const browserLang =
149
180
  typeof navigator !== "undefined" ? navigator.language : "en";
150
- if (_i18n.locales[browserLang]) _i18n.locale = browserLang;
181
+ const prefix = browserLang.split("-")[0];
182
+ if (_i18n.locales[browserLang]) _i18n._locale = browserLang;
183
+ else if (_i18n.locales[prefix]) _i18n._locale = prefix;
151
184
  }
152
185
  },
153
186
 
@@ -183,7 +216,7 @@ const NoJS = {
183
216
  resolve,
184
217
 
185
218
  // Version
186
- version: "1.0.2",
219
+ version: "1.2.0",
187
220
  };
188
221
 
189
222
  export default NoJS;