@erickxavier/no-js 1.5.2 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
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",
package/src/context.js CHANGED
@@ -2,19 +2,25 @@
2
2
  // REACTIVE CONTEXT
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _stores, _refs, _routerInstance, _currentEl } from "./globals.js";
5
+ import { _config, _stores, _refs, _routerInstance, _currentEl } from "./globals.js";
6
6
  import { _i18n } from "./i18n.js";
7
+ import { _devtoolsEmit, _ctxRegistry } from "./devtools.js";
7
8
 
8
9
  let _batchDepth = 0;
9
10
  const _batchQueue = new Set();
11
+ let _ctxId = 0;
12
+
13
+ export function _resetCtxId() { _ctxId = 0; }
10
14
 
11
15
  export function _startBatch() {
12
16
  _batchDepth++;
17
+ _devtoolsEmit("batch:start", { depth: _batchDepth });
13
18
  }
14
19
 
15
20
  export function _endBatch() {
16
21
  _batchDepth--;
17
22
  if (_batchDepth === 0 && _batchQueue.size > 0) {
23
+ _devtoolsEmit("batch:end", { depth: 0, queueSize: _batchQueue.size });
18
24
  const fns = [..._batchQueue];
19
25
  _batchQueue.clear();
20
26
  fns.forEach((fn) => {
@@ -28,6 +34,7 @@ export function createContext(data = {}, parent = null) {
28
34
  const listeners = new Set();
29
35
  const raw = {};
30
36
  Object.assign(raw, data);
37
+ if (_config.devtools) raw.__devtoolsId = ++_ctxId;
31
38
  let notifying = false;
32
39
 
33
40
  function notify() {
@@ -94,7 +101,15 @@ export function createContext(data = {}, parent = null) {
94
101
  set(target, key, value) {
95
102
  const old = target[key];
96
103
  target[key] = value;
97
- if (old !== value) notify();
104
+ if (old !== value) {
105
+ notify();
106
+ _devtoolsEmit("ctx:updated", {
107
+ id: target.__devtoolsId,
108
+ key,
109
+ oldValue: old,
110
+ newValue: value,
111
+ });
112
+ }
98
113
  return true;
99
114
  },
100
115
  has(target, key) {
@@ -105,6 +120,17 @@ export function createContext(data = {}, parent = null) {
105
120
  };
106
121
 
107
122
  const proxy = new Proxy(raw, handler);
123
+
124
+ if (_config.devtools && raw.__devtoolsId) {
125
+ _ctxRegistry.set(raw.__devtoolsId, proxy);
126
+ _devtoolsEmit("ctx:created", {
127
+ id: raw.__devtoolsId,
128
+ parentId: parent?.__raw?.__devtoolsId ?? null,
129
+ keys: Object.keys(data),
130
+ elementTag: _currentEl?.tagName?.toLowerCase() ?? null,
131
+ });
132
+ }
133
+
108
134
  return proxy;
109
135
  }
110
136
 
@@ -0,0 +1,264 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // DEVTOOLS PROTOCOL
3
+ // Zero-overhead runtime inspection, mutation, and monitoring.
4
+ // All hooks are guarded by _config.devtools — no cost when disabled.
5
+ // ═══════════════════════════════════════════════════════════════════════
6
+
7
+ import { _config, _stores, _refs, _routerInstance } from "./globals.js";
8
+ import { _i18n } from "./i18n.js";
9
+
10
+ // ─── Context registry (populated by createContext when devtools enabled) ────
11
+ // Maps __devtoolsId → Proxy reference for inspect/mutate commands.
12
+ export const _ctxRegistry = new Map();
13
+
14
+ // ─── Emit a devtools event ──────────────────────────────────────────────────
15
+ export function _devtoolsEmit(type, data) {
16
+ if (!_config.devtools || typeof window === "undefined") return;
17
+ window.dispatchEvent(
18
+ new CustomEvent("nojs:devtools", {
19
+ detail: { type, data, timestamp: Date.now() },
20
+ }),
21
+ );
22
+ }
23
+
24
+ // ─── Serialization helpers ──────────────────────────────────────────────────
25
+
26
+ function _safeSnapshot(proxy) {
27
+ if (!proxy || !proxy.__isProxy) return null;
28
+ const raw = proxy.__raw;
29
+ const snapshot = {};
30
+ for (const key of Object.keys(raw)) {
31
+ if (key.startsWith("__")) continue;
32
+ const val = raw[key];
33
+ if (val && typeof val === "object" && val.__isProxy) {
34
+ snapshot[key] = _safeSnapshot(val);
35
+ } else {
36
+ try {
37
+ // Verify serializable — drop functions and circular refs
38
+ JSON.stringify(val);
39
+ snapshot[key] = val;
40
+ } catch {
41
+ snapshot[key] = String(val);
42
+ }
43
+ }
44
+ }
45
+ return snapshot;
46
+ }
47
+
48
+ function _elementTag(el) {
49
+ if (!el || !el.tagName) return null;
50
+ const tag = el.tagName.toLowerCase();
51
+ const id = el.id ? `#${el.id}` : "";
52
+ const cls = el.className && typeof el.className === "string"
53
+ ? "." + el.className.trim().split(/\s+/).join(".")
54
+ : "";
55
+ return tag + id + cls;
56
+ }
57
+
58
+ // ─── Inspect commands ───────────────────────────────────────────────────────
59
+
60
+ function _inspectElement(selector) {
61
+ const el = document.querySelector(selector);
62
+ if (!el) return { error: "Element not found", selector };
63
+ const ctx = el.__ctx;
64
+ return {
65
+ selector,
66
+ tag: _elementTag(el),
67
+ hasContext: !!ctx,
68
+ contextId: ctx?.__raw?.__devtoolsId ?? null,
69
+ data: ctx ? _safeSnapshot(ctx) : null,
70
+ directives: [...el.attributes]
71
+ .filter((a) => !["class", "id", "style"].includes(a.name))
72
+ .map((a) => ({ name: a.name, value: a.value })),
73
+ };
74
+ }
75
+
76
+ function _inspectStore(name) {
77
+ const store = _stores[name];
78
+ if (!store) return { error: "Store not found", name };
79
+ return {
80
+ name,
81
+ contextId: store.__raw?.__devtoolsId ?? null,
82
+ data: _safeSnapshot(store),
83
+ };
84
+ }
85
+
86
+ function _inspectTree(selector) {
87
+ const root = selector ? document.querySelector(selector) : document.body;
88
+ if (!root) return { error: "Root not found", selector };
89
+
90
+ function walk(el) {
91
+ const ctx = el.__ctx;
92
+ const node = {
93
+ tag: _elementTag(el),
94
+ contextId: ctx?.__raw?.__devtoolsId ?? null,
95
+ children: [],
96
+ };
97
+ for (const child of el.children) {
98
+ if (child.tagName === "TEMPLATE" || child.tagName === "SCRIPT") continue;
99
+ if (child.__ctx || child.__declared) {
100
+ node.children.push(walk(child));
101
+ }
102
+ }
103
+ return node;
104
+ }
105
+
106
+ return walk(root);
107
+ }
108
+
109
+ function _mutateContext(id, key, value) {
110
+ const proxy = _ctxRegistry.get(id);
111
+ if (!proxy) return { error: "Context not found", id };
112
+ proxy[key] = value;
113
+ return { ok: true, id, key };
114
+ }
115
+
116
+ function _mutateStore(name, key, value) {
117
+ const store = _stores[name];
118
+ if (!store) return { error: "Store not found", name };
119
+ store[key] = value;
120
+ return { ok: true, name, key };
121
+ }
122
+
123
+ function _getStats() {
124
+ let listenerCount = 0;
125
+ for (const [, proxy] of _ctxRegistry) {
126
+ if (proxy.__listeners) listenerCount += proxy.__listeners.size;
127
+ }
128
+ return {
129
+ contexts: _ctxRegistry.size,
130
+ stores: Object.keys(_stores).length,
131
+ listeners: listenerCount,
132
+ refs: Object.keys(_refs).length,
133
+ hasRouter: !!_routerInstance,
134
+ locale: _i18n.locale,
135
+ };
136
+ }
137
+
138
+ // ─── Highlight overlay ──────────────────────────────────────────────────────
139
+
140
+ let _highlightOverlay = null;
141
+
142
+ function _highlightElement(selector) {
143
+ _unhighlight();
144
+ const el = document.querySelector(selector);
145
+ if (!el) return;
146
+ const rect = el.getBoundingClientRect();
147
+ _highlightOverlay = document.createElement("div");
148
+ _highlightOverlay.id = "__nojs_devtools_highlight__";
149
+ Object.assign(_highlightOverlay.style, {
150
+ position: "fixed",
151
+ top: rect.top + "px",
152
+ left: rect.left + "px",
153
+ width: rect.width + "px",
154
+ height: rect.height + "px",
155
+ background: "rgba(66, 133, 244, 0.25)",
156
+ border: "2px solid rgba(66, 133, 244, 0.8)",
157
+ pointerEvents: "none",
158
+ zIndex: "2147483647",
159
+ borderRadius: "3px",
160
+ });
161
+ document.body.appendChild(_highlightOverlay);
162
+ }
163
+
164
+ function _unhighlight() {
165
+ if (_highlightOverlay) {
166
+ _highlightOverlay.remove();
167
+ _highlightOverlay = null;
168
+ }
169
+ }
170
+
171
+ // ─── Command handler ────────────────────────────────────────────────────────
172
+
173
+ function _handleDevtoolsCommand(event) {
174
+ const { command, args = {} } = event.detail || {};
175
+ let result;
176
+
177
+ switch (command) {
178
+ case "inspect:element":
179
+ result = _inspectElement(args.selector);
180
+ break;
181
+ case "inspect:store":
182
+ result = _inspectStore(args.name);
183
+ break;
184
+ case "inspect:tree":
185
+ result = _inspectTree(args.selector);
186
+ break;
187
+ case "mutate:context":
188
+ result = _mutateContext(args.id, args.key, args.value);
189
+ break;
190
+ case "mutate:store":
191
+ result = _mutateStore(args.name, args.key, args.value);
192
+ break;
193
+ case "get:config":
194
+ result = { ..._config };
195
+ break;
196
+ case "get:routes":
197
+ result = _routerInstance ? _routerInstance.routes || [] : [];
198
+ break;
199
+ case "get:stats":
200
+ result = _getStats();
201
+ break;
202
+ case "highlight:element":
203
+ _highlightElement(args.selector);
204
+ result = { ok: true };
205
+ break;
206
+ case "unhighlight":
207
+ _unhighlight();
208
+ result = { ok: true };
209
+ break;
210
+ default:
211
+ result = { error: "Unknown command", command };
212
+ }
213
+
214
+ // Respond with result
215
+ window.dispatchEvent(
216
+ new CustomEvent("nojs:devtools:response", {
217
+ detail: { command, result, timestamp: Date.now() },
218
+ }),
219
+ );
220
+ }
221
+
222
+ // ─── Initialization ─────────────────────────────────────────────────────────
223
+
224
+ export function initDevtools(nojs) {
225
+ if (!_config.devtools || typeof window === "undefined") return;
226
+
227
+ // Listen for commands
228
+ window.addEventListener("nojs:devtools:cmd", _handleDevtoolsCommand);
229
+
230
+ // Expose public API on window
231
+ window.__NOJS_DEVTOOLS__ = {
232
+ // Data access
233
+ stores: _stores,
234
+ config: _config,
235
+ refs: _refs,
236
+ router: _routerInstance,
237
+ version: nojs.version,
238
+
239
+ // Inspect API
240
+ inspect: (selector) => _inspectElement(selector),
241
+ inspectStore: (name) => _inspectStore(name),
242
+ inspectTree: (selector) => _inspectTree(selector),
243
+ stats: () => _getStats(),
244
+
245
+ // Mutation API
246
+ mutate: (id, key, value) => _mutateContext(id, key, value),
247
+ mutateStore: (name, key, value) => _mutateStore(name, key, value),
248
+
249
+ // Visual
250
+ highlight: (selector) => _highlightElement(selector),
251
+ unhighlight: () => _unhighlight(),
252
+
253
+ // Event subscription shorthand
254
+ on: (type, fn) => {
255
+ const handler = (e) => {
256
+ if (!type || e.detail.type === type) fn(e.detail);
257
+ };
258
+ window.addEventListener("nojs:devtools", handler);
259
+ return () => window.removeEventListener("nojs:devtools", handler);
260
+ },
261
+ };
262
+
263
+ console.log("[No.JS DevTools] enabled — access via window.__NOJS_DEVTOOLS__");
264
+ }
@@ -16,6 +16,7 @@ import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
16
16
  import { _doFetch, _cacheGet, _cacheSet } from "../fetch.js";
17
17
  import { findContext, _clearDeclared, _cloneTemplate } from "../dom.js";
18
18
  import { registerDirective, processTree } from "../registry.js";
19
+ import { _devtoolsEmit } from "../devtools.js";
19
20
 
20
21
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
21
22
 
@@ -199,6 +200,7 @@ for (const method of HTTP_METHODS) {
199
200
  _routerInstance.push(redirectPath);
200
201
 
201
202
  _emitEvent("fetch:success", { url: resolvedUrl, data });
203
+ _devtoolsEmit("fetch:success", { method, url: resolvedUrl });
202
204
  } catch (err) {
203
205
  // SwitchMap: silently ignore aborted requests
204
206
  if (err.name === "AbortError") return;
@@ -209,6 +211,7 @@ for (const method of HTTP_METHODS) {
209
211
  );
210
212
  _emitEvent("fetch:error", { url: resolvedUrl, error: err });
211
213
  _emitEvent("error", { url: resolvedUrl, error: err });
214
+ _devtoolsEmit("fetch:error", { method, url: resolvedUrl, error: err.message });
212
215
 
213
216
  if (errorTpl) {
214
217
  const clone = _cloneTemplate(errorTpl);
@@ -7,6 +7,7 @@ import { createContext } from "../context.js";
7
7
  import { evaluate, _execStatement } from "../evaluate.js";
8
8
  import { findContext } from "../dom.js";
9
9
  import { registerDirective } from "../registry.js";
10
+ import { _devtoolsEmit } from "../devtools.js";
10
11
 
11
12
  registerDirective("state", {
12
13
  priority: 0,
@@ -63,6 +64,10 @@ registerDirective("store", {
63
64
  ? evaluate(valueAttr, createContext()) || {}
64
65
  : {};
65
66
  _stores[storeName] = createContext(data);
67
+ _devtoolsEmit("store:created", {
68
+ name: storeName,
69
+ keys: Object.keys(data),
70
+ });
66
71
  }
67
72
  _log("store", storeName);
68
73
  },
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ import { evaluate, resolve } from "./evaluate.js";
22
22
  import { findContext, _loadRemoteTemplates, _loadRemoteTemplatesPhase1, _loadRemoteTemplatesPhase2, _processTemplateIncludes } from "./dom.js";
23
23
  import { registerDirective, processTree } from "./registry.js";
24
24
  import { _createRouter } from "./router.js";
25
+ import { initDevtools } from "./devtools.js";
25
26
 
26
27
  // Side-effect imports: register built-in filters
27
28
  import "./filters.js";
@@ -126,18 +127,7 @@ const NoJS = {
126
127
  _loadRemoteTemplatesPhase2();
127
128
 
128
129
  // DevTools integration
129
- if (_config.devtools && typeof window !== "undefined") {
130
- window.__NOJS_DEVTOOLS__ = {
131
- stores: _stores,
132
- config: _config,
133
- refs: _refs,
134
- router: _routerInstance,
135
- filters: Object.keys(_filters),
136
- validators: Object.keys(_validators),
137
- version: NoJS.version,
138
- };
139
- _log("DevTools enabled — access via window.__NOJS_DEVTOOLS__");
140
- }
130
+ initDevtools(NoJS);
141
131
  },
142
132
 
143
133
  // Register custom directive
@@ -218,7 +208,7 @@ const NoJS = {
218
208
  resolve,
219
209
 
220
210
  // Version
221
- version: "1.5.2",
211
+ version: "1.6.0",
222
212
  };
223
213
 
224
214
  export default NoJS;
package/src/registry.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { _currentEl, _setCurrentEl, _storeWatchers } from "./globals.js";
6
6
  import { _i18nListeners } from "./i18n.js";
7
+ import { _devtoolsEmit, _ctxRegistry } from "./devtools.js";
7
8
 
8
9
  const _directives = new Map();
9
10
 
@@ -52,6 +53,13 @@ export function processElement(el) {
52
53
  m.init(el, m.name, m.value);
53
54
  }
54
55
  _setCurrentEl(prev);
56
+
57
+ if (matched.length > 0) {
58
+ _devtoolsEmit("directive:init", {
59
+ element: el.tagName?.toLowerCase(),
60
+ directives: matched.map((m) => ({ name: m.name, value: m.value })),
61
+ });
62
+ }
55
63
  }
56
64
 
57
65
  export function processTree(root) {
@@ -68,6 +76,8 @@ export function processTree(root) {
68
76
  // ─── Disposal: proactive cleanup of watchers/listeners/disposers ────────
69
77
 
70
78
  function _disposeElement(node) {
79
+ const ctxId = node.__ctx?.__raw?.__devtoolsId;
80
+
71
81
  if (node.__ctx && node.__ctx.__listeners) {
72
82
  for (const fn of node.__ctx.__listeners) {
73
83
  _storeWatchers.delete(fn);
@@ -80,6 +90,14 @@ function _disposeElement(node) {
80
90
  node.__disposers = null;
81
91
  }
82
92
  node.__declared = false;
93
+
94
+ if (ctxId != null) {
95
+ _ctxRegistry.delete(ctxId);
96
+ _devtoolsEmit("ctx:disposed", {
97
+ id: ctxId,
98
+ elementTag: node.tagName?.toLowerCase(),
99
+ });
100
+ }
83
101
  }
84
102
 
85
103
  export function _disposeTree(root) {
package/src/router.js CHANGED
@@ -8,6 +8,7 @@ import { evaluate } from "./evaluate.js";
8
8
  import { findContext, _clearDeclared, _loadTemplateElement, _processTemplateIncludes } from "./dom.js";
9
9
  import { processTree, _disposeTree } from "./registry.js";
10
10
  import { _animateIn } from "./animations.js";
11
+ import { _devtoolsEmit } from "./devtools.js";
11
12
 
12
13
  export function _createRouter() {
13
14
  const routes = [];
@@ -101,6 +102,13 @@ export function _createRouter() {
101
102
  await _renderRoute(matched);
102
103
  listeners.forEach((fn) => fn(current));
103
104
 
105
+ _devtoolsEmit("route:navigate", {
106
+ path: current.path,
107
+ params: current.params,
108
+ query: current.query,
109
+ hash: current.hash,
110
+ });
111
+
104
112
  // Scroll to anchor if hash is present (e.g. route="/docs#cheatsheet")
105
113
  if (current.hash) {
106
114
  const anchorId = current.hash.slice(1);