zero-query 0.5.2 → 0.6.3

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * cli/commands/dev/validator.js — JS syntax validation
3
+ *
4
+ * Pre-validates JavaScript files on save using Node's VM module.
5
+ * Returns structured error descriptors with code frames compatible
6
+ * with the browser error overlay.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const vm = require('vm');
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Code frame generator
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Build a code frame showing ~4 lines of context around the error
20
+ * with a caret pointer at the offending column.
21
+ *
22
+ * @param {string} source — full file contents
23
+ * @param {number} line — 1-based line number
24
+ * @param {number} column — 1-based column number
25
+ * @returns {string}
26
+ */
27
+ function generateCodeFrame(source, line, column) {
28
+ const lines = source.split('\n');
29
+ const start = Math.max(0, line - 4);
30
+ const end = Math.min(lines.length, line + 3);
31
+ const pad = String(end).length;
32
+ const frame = [];
33
+
34
+ for (let i = start; i < end; i++) {
35
+ const num = String(i + 1).padStart(pad);
36
+ const marker = i === line - 1 ? '>' : ' ';
37
+ frame.push(`${marker} ${num} | ${lines[i]}`);
38
+ if (i === line - 1 && column > 0) {
39
+ frame.push(` ${' '.repeat(pad)} | ${' '.repeat(column - 1)}^`);
40
+ }
41
+ }
42
+ return frame.join('\n');
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // JS validation
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Validate a JavaScript file for syntax errors.
51
+ *
52
+ * Strips ESM import/export statements (preserving line numbers) so the
53
+ * VM can parse module-style code, then compiles via vm.Script.
54
+ *
55
+ * @param {string} filePath — absolute path to the file
56
+ * @param {string} relPath — display-friendly relative path
57
+ * @returns {object|null} — error descriptor, or null if valid
58
+ */
59
+ function validateJS(filePath, relPath) {
60
+ let source;
61
+ try { source = fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
62
+
63
+ const normalized = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
64
+ const stripped = normalized.split('\n').map(line => {
65
+ if (/^\s*import\s+.*from\s+['"]/.test(line)) return ' '.repeat(line.length);
66
+ if (/^\s*import\s+['"]/.test(line)) return ' '.repeat(line.length);
67
+ if (/^\s*export\s*\{/.test(line)) return ' '.repeat(line.length);
68
+ line = line.replace(/^(\s*)export\s+default\s+/, '$1');
69
+ line = line.replace(/^(\s*)export\s+(const|let|var|function|class|async\s+function)\s/, '$1$2 ');
70
+ line = line.replace(/import\.meta\.url/g, "'__meta__'");
71
+ line = line.replace(/import\.meta/g, '({})');
72
+ return line;
73
+ }).join('\n');
74
+
75
+ try {
76
+ new vm.Script(stripped, { filename: relPath });
77
+ return null;
78
+ } catch (err) {
79
+ const line = err.stack ? parseInt((err.stack.match(/:(\d+)/) || [])[1]) || 0 : 0;
80
+ const col = err.stack ? parseInt((err.stack.match(/:(\d+):(\d+)/) || [])[2]) || 0 : 0;
81
+ const frame = line > 0 ? generateCodeFrame(source, line, col) : '';
82
+ return {
83
+ code: 'ZQ_DEV_SYNTAX',
84
+ type: err.constructor.name || 'SyntaxError',
85
+ message: err.message,
86
+ file: relPath,
87
+ line,
88
+ column: col,
89
+ frame,
90
+ };
91
+ }
92
+ }
93
+
94
+ module.exports = { generateCodeFrame, validateJS };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * cli/commands/dev/watcher.js — File system watcher
3
+ *
4
+ * Recursively watches the project root for file changes, validates
5
+ * JS files for syntax errors, and broadcasts reload / CSS hot-swap /
6
+ * error events through the SSE pool.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const { validateJS } = require('./validator');
15
+ const { logCSS, logReload, logError } = require('./logger');
16
+
17
+ const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.cache']);
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function shouldWatch(filename) {
24
+ return !!filename && !filename.startsWith('.');
25
+ }
26
+
27
+ function isIgnored(filepath) {
28
+ return filepath.split(path.sep).some(p => IGNORE_DIRS.has(p));
29
+ }
30
+
31
+ /** Recursively collect every directory under `dir` (excluding ignored). */
32
+ function collectWatchDirs(dir) {
33
+ const dirs = [dir];
34
+ try {
35
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
36
+ if (!entry.isDirectory() || IGNORE_DIRS.has(entry.name)) continue;
37
+ dirs.push(...collectWatchDirs(path.join(dir, entry.name)));
38
+ }
39
+ } catch { /* unreadable dir — skip */ }
40
+ return dirs;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Watcher factory
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Start watching `root` for file changes.
49
+ *
50
+ * @param {object} opts
51
+ * @param {string} opts.root — absolute project root
52
+ * @param {SSEPool} opts.pool — SSE broadcast pool
53
+ * @returns {{ dirs: string[], destroy: Function }}
54
+ */
55
+ function startWatcher({ root, pool }) {
56
+ const watchDirs = collectWatchDirs(root);
57
+ const watchers = [];
58
+
59
+ let debounceTimer;
60
+ let currentError = null; // track which file has an active error
61
+
62
+ for (const dir of watchDirs) {
63
+ try {
64
+ const watcher = fs.watch(dir, (_, filename) => {
65
+ if (!shouldWatch(filename)) return;
66
+ const fullPath = path.join(dir, filename || '');
67
+ if (isIgnored(fullPath)) return;
68
+
69
+ clearTimeout(debounceTimer);
70
+ debounceTimer = setTimeout(() => {
71
+ const rel = path.relative(root, fullPath).replace(/\\/g, '/');
72
+ const ext = path.extname(filename).toLowerCase();
73
+
74
+ // ---- CSS hot-swap ----
75
+ if (ext === '.css') {
76
+ logCSS(rel);
77
+ pool.broadcast('css', rel);
78
+ return;
79
+ }
80
+
81
+ // ---- JS syntax check ----
82
+ if (ext === '.js') {
83
+ const err = validateJS(fullPath, rel);
84
+ if (err) {
85
+ currentError = rel;
86
+ logError(err);
87
+ pool.broadcast('error:syntax', JSON.stringify(err));
88
+ return;
89
+ }
90
+ // File was fixed — clear previous overlay
91
+ if (currentError === rel) {
92
+ currentError = null;
93
+ pool.broadcast('error:clear', '');
94
+ }
95
+ }
96
+
97
+ // ---- Full reload ----
98
+ logReload(rel);
99
+ pool.broadcast('reload', rel);
100
+ }, 100);
101
+ });
102
+ watchers.push(watcher);
103
+ } catch { /* dir became inaccessible — skip */ }
104
+ }
105
+
106
+ function destroy() {
107
+ clearTimeout(debounceTimer);
108
+ watchers.forEach(w => w.close());
109
+ }
110
+
111
+ return { dirs: watchDirs, destroy };
112
+ }
113
+
114
+ module.exports = { startWatcher, collectWatchDirs };
Binary file
@@ -46,6 +46,18 @@ $.component('about-page', {
46
46
  <strong>$.component()</strong>
47
47
  <span>Reactive components with state, lifecycle hooks, and template rendering</span>
48
48
  </div>
49
+ <div class="feature-item">
50
+ <strong>computed / watch</strong>
51
+ <span>Derived state properties and reactive watchers on the counter page</span>
52
+ </div>
53
+ <div class="feature-item">
54
+ <strong>DOM Diffing</strong>
55
+ <span>Efficient <code>morph()</code> engine patches only changed DOM nodes on re-render</span>
56
+ </div>
57
+ <div class="feature-item">
58
+ <strong>z-key</strong>
59
+ <span>Keyed list reconciliation in z-for loops (todos, counter history, contacts)</span>
60
+ </div>
49
61
  <div class="feature-item">
50
62
  <strong>$.router()</strong>
51
63
  <span>SPA routing with history mode, z-link navigation, and fallback pages</span>
@@ -79,8 +91,8 @@ $.component('about-page', {
79
91
  <span>Safe rendering of user-generated and API content</span>
80
92
  </div>
81
93
  <div class="feature-item">
82
- <strong>$.uuid()</strong>
83
- <span>Unique ID generation for new todo items</span>
94
+ <strong>CSP-safe expressions</strong>
95
+ <span>Template expressions evaluated without <code>eval()</code> or <code>new Function()</code></span>
84
96
  </div>
85
97
  <div class="feature-item">
86
98
  <strong>z-model / z-ref</strong>
@@ -13,7 +13,7 @@
13
13
  <h1>Contacts</h1>
14
14
  <p class="subtitle">
15
15
  External template &amp; styles via <code>templateUrl</code> / <code>styleUrl</code>.
16
- Directives: <code>z-if</code>, <code>z-for</code>, <code>z-show</code>,
16
+ Directives: <code>z-if</code>, <code>z-for</code> + <code>z-key</code>, <code>z-show</code>,
17
17
  <code>z-bind</code>, <code>z-class</code>, <code>z-model</code>, and more.
18
18
  </p>
19
19
  </div>
@@ -73,6 +73,7 @@
73
73
  <ul class="contacts-list">
74
74
  <li
75
75
  z-for="contact in contacts"
76
+ z-key="{{contact.id}}"
76
77
  class="contacts-item {{contact.id === selectedId ? 'selected' : ''}} {{contact.favorite ? 'is-favorite' : ''}}"
77
78
  @click="selectContact({{contact.id}})"
78
79
  >
@@ -111,15 +112,15 @@
111
112
  <div class="card contact-detail" z-if="selectedId !== null">
112
113
  <div class="detail-header">
113
114
  <div>
114
- <h3 z-text="contacts.find(c => c.id === selectedId)?.name || ''"></h3>
115
- <p class="muted" z-text="contacts.find(c => c.id === selectedId)?.email || ''"></p>
115
+ <h3 z-text="selectedName"></h3>
116
+ <p class="muted" z-text="selectedEmail"></p>
116
117
  </div>
117
118
  <div class="detail-actions">
118
119
  <button
119
120
  class="btn btn-outline btn-sm"
120
121
  @click="cycleStatus({{selectedId}})"
121
122
  >
122
- Status: <span z-text="contacts.find(c => c.id === selectedId)?.status || ''"></span>
123
+ Status: <span z-text="selectedStatus"></span>
123
124
  </button>
124
125
 
125
126
  <!-- Confirm delete pattern using z-if / z-else -->
@@ -23,6 +23,9 @@ $.component('contacts-page', {
23
23
  nameError: '',
24
24
  emailError: '',
25
25
  selectedId: null,
26
+ selectedName: '',
27
+ selectedEmail: '',
28
+ selectedStatus: '',
26
29
  confirmDeleteId: null,
27
30
  totalAdded: 0,
28
31
  favoriteCount: 0,
@@ -42,6 +45,7 @@ $.component('contacts-page', {
42
45
  this.state.contacts = store.state.contacts;
43
46
  this.state.totalAdded = store.state.contactsAdded;
44
47
  this.state.favoriteCount = store.getters.favoriteCount;
48
+ this._syncSelected();
45
49
  },
46
50
 
47
51
  // -- Actions --
@@ -101,8 +105,10 @@ $.component('contacts-page', {
101
105
  },
102
106
 
103
107
  selectContact(id) {
104
- this.state.selectedId = this.state.selectedId === Number(id) ? null : Number(id);
108
+ const numId = Number(id);
109
+ this.state.selectedId = this.state.selectedId === numId ? null : numId;
105
110
  this.state.confirmDeleteId = null;
111
+ this._syncSelected();
106
112
  },
107
113
 
108
114
  confirmDelete(id) {
@@ -125,6 +131,16 @@ $.component('contacts-page', {
125
131
 
126
132
  cycleStatus(id) {
127
133
  $.getStore('main').dispatch('cycleContactStatus', Number(id));
134
+ this._syncSelected();
135
+ },
136
+
137
+ _syncSelected() {
138
+ const c = this.state.selectedId != null
139
+ ? this.state.contacts.find(c => c.id === this.state.selectedId)
140
+ : null;
141
+ this.state.selectedName = c ? c.name : '';
142
+ this.state.selectedEmail = c ? c.email : '';
143
+ this.state.selectedStatus = c ? c.status : '';
128
144
  },
129
145
 
130
146
  _clearForm() {
@@ -1,8 +1,9 @@
1
1
  // scripts/components/counter.js — interactive counter
2
2
  //
3
- // Demonstrates: component state, instance methods, @click event binding,
4
- // z-model two-way binding with z-number modifier, z-class,
5
- // z-if, z-for, $.bus toast notifications
3
+ // Demonstrates: component state, computed properties, watch callbacks,
4
+ // @click event binding, z-model two-way binding with
5
+ // z-number modifier, z-class, z-if, z-for with z-key,
6
+ // $.bus toast notifications
6
7
 
7
8
  $.component('counter-page', {
8
9
  state: () => ({
@@ -11,16 +12,35 @@ $.component('counter-page', {
11
12
  history: [],
12
13
  }),
13
14
 
15
+ // Computed properties — derived values that update automatically
16
+ computed: {
17
+ isNegative: (state) => state.count < 0,
18
+ historyCount: (state) => state.history.length,
19
+ lastAction: (state) => state.history.length ? state.history[state.history.length - 1] : null,
20
+ },
21
+
22
+ // Watch — react to specific state changes
23
+ watch: {
24
+ count(val) {
25
+ if (val === 100) $.bus.emit('toast', { message: 'Century! 🎉', type: 'success' });
26
+ if (val === -100) $.bus.emit('toast', { message: 'Negative century!', type: 'error' });
27
+ },
28
+ },
29
+
14
30
  increment() {
15
31
  this.state.count += this.state.step;
16
- this.state.history.push({ action: '+', value: this.state.step, result: this.state.count });
17
- if (this.state.history.length > 8) this.state.history.shift();
32
+ this._pushHistory('+', this.state.step, this.state.count);
18
33
  },
19
34
 
20
35
  decrement() {
21
36
  this.state.count -= this.state.step;
22
- this.state.history.push({ action: '−', value: this.state.step, result: this.state.count });
23
- if (this.state.history.length > 8) this.state.history.shift();
37
+ this._pushHistory('−', this.state.step, this.state.count);
38
+ },
39
+
40
+ _pushHistory(action, value, result) {
41
+ const raw = this.state.history.__raw || this.state.history;
42
+ const next = [...raw, { id: Date.now(), action, value, result }];
43
+ this.state.history = next.length > 8 ? next.slice(-8) : next;
24
44
  },
25
45
 
26
46
  reset() {
@@ -33,7 +53,7 @@ $.component('counter-page', {
33
53
  return `
34
54
  <div class="page-header">
35
55
  <h1>Counter</h1>
36
- <p class="subtitle">Component state, <code>@click</code> handlers, <code>z-model</code>, <code>z-class</code>, and <code>z-for</code>.</p>
56
+ <p class="subtitle"><code>computed</code>, <code>watch</code>, <code>@click</code>, <code>z-model</code>, <code>z-class</code>, and <code>z-for</code> with <code>z-key</code>.</p>
37
57
  </div>
38
58
 
39
59
  <div class="card counter-card">
@@ -55,9 +75,9 @@ $.component('counter-page', {
55
75
  </div>
56
76
 
57
77
  <div class="card card-muted" z-if="history.length > 0">
58
- <h3>History</h3>
78
+ <h3>History <small style="color:var(--text-muted);font-weight:400;">(${this.computed.historyCount} entries)</small></h3>
59
79
  <div class="history-list">
60
- <span z-for="e in history" class="history-item">{{e.action}}{{e.value}} → <strong>{{e.result}}</strong></span>
80
+ <span z-for="e in history" z-key="{{e.id}}" class="history-item">{{e.action}}{{e.value}} → <strong>{{e.result}}</strong></span>
61
81
  </div>
62
82
  </div>
63
83
  `;
@@ -62,19 +62,19 @@ $.component('home-page', {
62
62
 
63
63
  <div class="card">
64
64
  <h3>🔢 Counter</h3>
65
- <p>Component state, two-way binding with <code>z-model</code>, and event handling.</p>
65
+ <p><code>computed</code> properties, <code>watch</code> callbacks, and <code>z-for</code> with <code>z-key</code> diffing.</p>
66
66
  <a z-link="/counter" class="btn btn-outline">Try It →</a>
67
67
  </div>
68
68
 
69
69
  <div class="card">
70
70
  <h3>✅ Todos</h3>
71
- <p>Global store with actions & getters. <strong>${store.getters.todoCount}</strong> items, <strong>${store.getters.doneCount}</strong> done.</p>
71
+ <p>Global store, <code>z-key</code> keyed lists, DOM diffing. <strong>${store.getters.todoCount}</strong> items, <strong>${store.getters.doneCount}</strong> done.</p>
72
72
  <a z-link="/todos" class="btn btn-outline">Try It →</a>
73
73
  </div>
74
74
 
75
75
  <div class="card">
76
76
  <h3>📇 Contacts</h3>
77
- <p>External templates &amp; styles via <code>templateUrl</code> / <code>styleUrl</code>. <strong>${store.getters.contactCount}</strong> contacts, <strong>${store.getters.favoriteCount}</strong> ★ favorited.</p>
77
+ <p>External templates, scoped styles, and <code>z-key</code> keyed lists. <strong>${store.getters.contactCount}</strong> contacts, <strong>${store.getters.favoriteCount}</strong> ★ favorited.</p>
78
78
  <a z-link="/contacts" class="btn btn-outline">Try It →</a>
79
79
  </div>
80
80
 
@@ -1,9 +1,10 @@
1
1
  // scripts/components/todos.js — todo list with global store
2
2
  //
3
3
  // Demonstrates: $.getStore, store.dispatch, store.subscribe,
4
- // store getters, z-model, z-ref, z-class, z-for,
5
- // z-if, z-show, @click with args, @submit.prevent,
6
- // mounted/destroyed lifecycle, $.bus toast, $.debounce
4
+ // store getters, computed properties, z-model, z-ref,
5
+ // z-class, z-for with z-key, z-if, z-show, @click
6
+ // with args, @submit.prevent, mounted/destroyed
7
+ // lifecycle, $.bus toast, $.debounce
7
8
 
8
9
  $.component('todos-page', {
9
10
  state: () => ({
@@ -83,7 +84,7 @@ $.component('todos-page', {
83
84
  return `
84
85
  <div class="page-header">
85
86
  <h1>Todos</h1>
86
- <p class="subtitle">Global store with <code>$.store()</code>, <code>z-for</code>, <code>z-class</code>, <code>z-if</code>, and <code>z-show</code>.</p>
87
+ <p class="subtitle">Global store with <code>$.store()</code>, <code>z-for</code> + <code>z-key</code>, <code>z-class</code>, <code>z-if</code>, and <code>z-show</code>.</p>
87
88
  </div>
88
89
 
89
90
  <div class="card">
@@ -114,7 +115,7 @@ $.component('todos-page', {
114
115
  </div>
115
116
 
116
117
  <ul z-else class="todo-list">
117
- <li z-for="t in filtered" class="todo-item {{t.done ? 'done' : ''}}">
118
+ <li z-for="t in filtered" z-key="{{t.id}}" class="todo-item {{t.done ? 'done' : ''}}">
118
119
  <button class="todo-check" @click="toggleTodo('{{t.id}}')"></button>
119
120
  <span class="todo-text">{{$.escapeHtml(t.text)}}</span>
120
121
  <button class="todo-remove" @click="removeTodo('{{t.id}}')">✕</button>
Binary file