zero-query 0.5.2 → 0.7.5

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.
Files changed (58) hide show
  1. package/README.md +12 -10
  2. package/cli/commands/build.js +7 -5
  3. package/cli/commands/bundle.js +286 -8
  4. package/cli/commands/dev/index.js +82 -0
  5. package/cli/commands/dev/logger.js +70 -0
  6. package/cli/commands/dev/overlay.js +366 -0
  7. package/cli/commands/dev/server.js +158 -0
  8. package/cli/commands/dev/validator.js +94 -0
  9. package/cli/commands/dev/watcher.js +147 -0
  10. package/cli/scaffold/favicon.ico +0 -0
  11. package/cli/scaffold/index.html +1 -0
  12. package/cli/scaffold/scripts/app.js +15 -22
  13. package/cli/scaffold/scripts/components/about.js +14 -2
  14. package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
  15. package/cli/scaffold/scripts/components/contacts/contacts.html +8 -7
  16. package/cli/scaffold/scripts/components/contacts/contacts.js +17 -1
  17. package/cli/scaffold/scripts/components/counter.js +30 -10
  18. package/cli/scaffold/scripts/components/home.js +3 -3
  19. package/cli/scaffold/scripts/components/todos.js +6 -5
  20. package/cli/scaffold/styles/styles.css +1 -0
  21. package/cli/utils.js +111 -6
  22. package/dist/zquery.dist.zip +0 -0
  23. package/dist/zquery.js +2005 -216
  24. package/dist/zquery.min.js +3 -13
  25. package/index.d.ts +149 -1080
  26. package/index.js +18 -7
  27. package/package.json +9 -3
  28. package/src/component.js +186 -45
  29. package/src/core.js +327 -35
  30. package/src/diff.js +280 -0
  31. package/src/errors.js +155 -0
  32. package/src/expression.js +806 -0
  33. package/src/http.js +18 -10
  34. package/src/reactive.js +29 -4
  35. package/src/router.js +59 -6
  36. package/src/ssr.js +224 -0
  37. package/src/store.js +24 -8
  38. package/tests/component.test.js +304 -0
  39. package/tests/core.test.js +726 -0
  40. package/tests/diff.test.js +194 -0
  41. package/tests/errors.test.js +162 -0
  42. package/tests/expression.test.js +334 -0
  43. package/tests/http.test.js +181 -0
  44. package/tests/reactive.test.js +191 -0
  45. package/tests/router.test.js +332 -0
  46. package/tests/store.test.js +253 -0
  47. package/tests/utils.test.js +353 -0
  48. package/types/collection.d.ts +368 -0
  49. package/types/component.d.ts +210 -0
  50. package/types/errors.d.ts +103 -0
  51. package/types/http.d.ts +81 -0
  52. package/types/misc.d.ts +166 -0
  53. package/types/reactive.d.ts +76 -0
  54. package/types/router.d.ts +132 -0
  55. package/types/ssr.d.ts +49 -0
  56. package/types/store.d.ts +107 -0
  57. package/types/utils.d.ts +142 -0
  58. /package/cli/commands/{dev.js → dev.old.js} +0 -0
@@ -0,0 +1,147 @@
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
+ /**
32
+ * Return the file's mtime as a millisecond timestamp, or 0 if unreadable.
33
+ * Used to ignore spurious fs.watch events (Windows fires on reads too).
34
+ */
35
+ function mtime(filepath) {
36
+ try { return fs.statSync(filepath).mtimeMs; } catch { return 0; }
37
+ }
38
+
39
+ /** Recursively collect every directory under `dir` (excluding ignored). */
40
+ function collectWatchDirs(dir) {
41
+ const dirs = [dir];
42
+ try {
43
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ if (!entry.isDirectory() || IGNORE_DIRS.has(entry.name)) continue;
45
+ dirs.push(...collectWatchDirs(path.join(dir, entry.name)));
46
+ }
47
+ } catch { /* unreadable dir — skip */ }
48
+ return dirs;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Watcher factory
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Start watching `root` for file changes.
57
+ *
58
+ * @param {object} opts
59
+ * @param {string} opts.root — absolute project root
60
+ * @param {SSEPool} opts.pool — SSE broadcast pool
61
+ * @returns {{ dirs: string[], destroy: Function }}
62
+ */
63
+ function startWatcher({ root, pool }) {
64
+ const watchDirs = collectWatchDirs(root);
65
+ const watchers = [];
66
+
67
+ let debounceTimer;
68
+ let currentError = null; // track which file has an active error
69
+
70
+ // Track file mtimes so we only react to genuine writes.
71
+ // On Windows, fs.watch fires on reads/access too, which causes
72
+ // spurious reloads the first time the server serves a file.
73
+ // We seed the cache with current mtimes so the first real save
74
+ // (which changes the mtime) is always detected.
75
+ const mtimeCache = new Map();
76
+ for (const d of watchDirs) {
77
+ try {
78
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
79
+ if (entry.isFile()) {
80
+ const fp = path.join(d, entry.name);
81
+ const mt = mtime(fp);
82
+ if (mt) mtimeCache.set(fp, mt);
83
+ }
84
+ }
85
+ } catch { /* skip */ }
86
+ }
87
+
88
+ for (const dir of watchDirs) {
89
+ try {
90
+ const watcher = fs.watch(dir, (_, filename) => {
91
+ if (!shouldWatch(filename)) return;
92
+ const fullPath = path.join(dir, filename || '');
93
+ if (isIgnored(fullPath)) return;
94
+
95
+ clearTimeout(debounceTimer);
96
+ debounceTimer = setTimeout(() => {
97
+ // Skip if the file hasn't actually been modified
98
+ const mt = mtime(fullPath);
99
+ if (mt === 0) return; // deleted or unreadable
100
+ const prev = mtimeCache.get(fullPath);
101
+ mtimeCache.set(fullPath, mt);
102
+ if (prev !== undefined && mt === prev) return; // unchanged
103
+
104
+ const rel = path.relative(root, fullPath).replace(/\\/g, '/');
105
+ const ext = path.extname(filename).toLowerCase();
106
+
107
+ // ---- CSS hot-swap ----
108
+ if (ext === '.css') {
109
+ logCSS(rel);
110
+ pool.broadcast('css', rel);
111
+ return;
112
+ }
113
+
114
+ // ---- JS syntax check ----
115
+ if (ext === '.js') {
116
+ const err = validateJS(fullPath, rel);
117
+ if (err) {
118
+ currentError = rel;
119
+ logError(err);
120
+ pool.broadcast('error:syntax', JSON.stringify(err));
121
+ return;
122
+ }
123
+ // File was fixed — clear previous overlay
124
+ if (currentError === rel) {
125
+ currentError = null;
126
+ pool.broadcast('error:clear', '');
127
+ }
128
+ }
129
+
130
+ // ---- Full reload ----
131
+ logReload(rel);
132
+ pool.broadcast('reload', rel);
133
+ }, 100);
134
+ });
135
+ watchers.push(watcher);
136
+ } catch { /* dir became inaccessible — skip */ }
137
+ }
138
+
139
+ function destroy() {
140
+ clearTimeout(debounceTimer);
141
+ watchers.forEach(w => w.close());
142
+ }
143
+
144
+ return { dirs: watchDirs, destroy };
145
+ }
146
+
147
+ module.exports = { startWatcher, collectWatchDirs };
Binary file
@@ -6,6 +6,7 @@
6
6
  <title>{{NAME}}</title>
7
7
  <base href="/">
8
8
  <link rel="stylesheet" href="styles/styles.css">
9
+ <link rel="icon" type="image/png" href="favicon.ico">
9
10
  <script src="scripts/vendor/zquery.min.js"></script>
10
11
  <script type="module" src="scripts/app.js"></script>
11
12
  </head>
@@ -27,7 +27,7 @@ const router = $.router({
27
27
  // Highlight the active nav link on every route change
28
28
  router.onChange((to) => {
29
29
  $.all('.nav-link').removeClass('active');
30
- $.all(`.nav-link[z-link="${to.path}"]`).addClass('active');
30
+ $(`.nav-link[z-link="${to.path}"]`).addClass('active');
31
31
 
32
32
  // Close mobile menu on navigate
33
33
  closeMobileMenu();
@@ -36,26 +36,20 @@ router.onChange((to) => {
36
36
  // ---------------------------------------------------------------------------
37
37
  // Responsive sidebar toggle
38
38
  // ---------------------------------------------------------------------------
39
- const sidebar = $.id('sidebar');
40
- const overlay = $.id('overlay');
41
- const toggle = $.id('menu-toggle');
39
+ const $sidebar = $('#sidebar');
40
+ const $overlay = $('#overlay');
41
+ const $toggle = $('#menu-toggle');
42
42
 
43
- function openMobileMenu() {
44
- sidebar.classList.add('open');
45
- overlay.classList.add('visible');
46
- toggle.classList.add('active');
43
+ function toggleMobileMenu(open) {
44
+ $sidebar.toggleClass('open', open);
45
+ $overlay.toggleClass('visible', open);
46
+ $toggle.toggleClass('active', open);
47
47
  }
48
48
 
49
- function closeMobileMenu() {
50
- sidebar.classList.remove('open');
51
- overlay.classList.remove('visible');
52
- toggle.classList.remove('active');
53
- }
49
+ function closeMobileMenu() { toggleMobileMenu(false); }
54
50
 
55
51
  // $.on — global delegated event listeners
56
- $.on('click', '#menu-toggle', () => {
57
- sidebar.classList.contains('open') ? closeMobileMenu() : openMobileMenu();
58
- });
52
+ $.on('click', '#menu-toggle', () => toggleMobileMenu(!$sidebar.hasClass('open')));
59
53
  $.on('click', '#overlay', closeMobileMenu);
60
54
 
61
55
  // Close sidebar on Escape key — using $.on direct (no selector needed)
@@ -69,14 +63,13 @@ $.on('keydown', (e) => {
69
63
  // Any component can emit: $.bus.emit('toast', { message, type })
70
64
  // Types: 'success', 'error', 'info'
71
65
  $.bus.on('toast', ({ message, type = 'info' }) => {
72
- const container = $.id('toasts');
73
- const toast = $.create('div');
74
- toast.className = `toast toast-${type}`;
75
- toast.textContent = message;
76
- container.appendChild(toast);
66
+ const toast = $.create('div')
67
+ .addClass('toast', `toast-${type}`)
68
+ .text(message)
69
+ .appendTo('#toasts');
77
70
  // Auto-remove after 3 seconds
78
71
  setTimeout(() => {
79
- toast.classList.add('toast-exit');
72
+ toast.addClass('toast-exit');
80
73
  setTimeout(() => toast.remove(), 300);
81
74
  }, 3000);
82
75
  });
@@ -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>
@@ -216,13 +216,6 @@
216
216
  font-weight: 500;
217
217
  }
218
218
 
219
- @keyframes slide-in {
220
- from { opacity: 0; transform: translateY(-6px); }
221
- to { opacity: 1; transform: translateY(0); }
222
- }
223
- }
224
-
225
- /* -- Animation -- */
226
219
  @keyframes slide-in {
227
220
  from { opacity: 0; transform: translateY(-6px); }
228
221
  to { opacity: 1; transform: translateY(0); }
@@ -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>
@@ -68,11 +68,12 @@
68
68
  </div>
69
69
 
70
70
  <!-- Contact count -->
71
- <div class="card" z-if="contacts.length > 0">
71
+ <div class="card" z-if="contacts.length > 0" z-key="contacts-list">
72
72
  <!-- Contacts list — z-for renders each item -->
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
  >
@@ -101,25 +102,25 @@
101
102
  </div>
102
103
 
103
104
  <!-- Empty state -->
104
- <div class="card" z-else>
105
+ <div class="card" z-else z-key="contacts-empty">
105
106
  <div class="empty-state">
106
107
  <p>No contacts yet — add one above!</p>
107
108
  </div>
108
109
  </div>
109
110
 
110
111
  <!-- Selected contact detail panel — z-if conditional rendering -->
111
- <div class="card contact-detail" z-if="selectedId !== null">
112
+ <div class="card contact-detail" z-if="selectedId !== null" z-key="contact-detail">
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>
@@ -103,6 +103,7 @@ code { background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-s
103
103
  transition: all 0.15s ease;
104
104
  border-left: 3px solid transparent;
105
105
  cursor: pointer;
106
+ user-select: none;
106
107
  }
107
108
 
108
109
  .nav-link:hover {
package/cli/utils.js CHANGED
@@ -83,15 +83,120 @@ function stripComments(code) {
83
83
  }
84
84
 
85
85
  // ---------------------------------------------------------------------------
86
- // minify — quick minification (strips comments + collapses whitespace)
86
+ // minify — single-pass minification
87
+ // Strips comments, collapses whitespace to the minimum required,
88
+ // and preserves string / template-literal / regex content verbatim.
87
89
  // ---------------------------------------------------------------------------
88
90
 
89
91
  function minify(code, banner) {
90
- const body = stripComments(code.replace(banner, ''))
91
- .replace(/^\s*\n/gm, '')
92
- .replace(/\n\s+/g, '\n')
93
- .replace(/\s{2,}/g, ' ');
94
- return banner + '\n' + body;
92
+ return banner + '\n' + _minifyBody(code.replace(banner, ''));
93
+ }
94
+
95
+ /**
96
+ * Single-pass minifier: walks character-by-character, skips strings/regex,
97
+ * strips comments, and emits a space only when both neighbours are
98
+ * identifier-like characters (or when collapsing would create ++, --, // or /*).
99
+ */
100
+ function _minifyBody(code) {
101
+ let out = '';
102
+ let i = 0;
103
+
104
+ while (i < code.length) {
105
+ const ch = code[i];
106
+ const nx = code[i + 1];
107
+
108
+ // ── String / template literal: copy verbatim ────────────────
109
+ if (ch === '"' || ch === "'" || ch === '`') {
110
+ const q = ch;
111
+ out += ch; i++;
112
+ while (i < code.length) {
113
+ if (code[i] === '\\') { out += code[i] + (code[i + 1] || ''); i += 2; continue; }
114
+ out += code[i];
115
+ if (code[i] === q) { i++; break; }
116
+ i++;
117
+ }
118
+ continue;
119
+ }
120
+
121
+ // ── Block comment: skip ─────────────────────────────────────
122
+ if (ch === '/' && nx === '*') {
123
+ i += 2;
124
+ while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++;
125
+ i += 2;
126
+ continue;
127
+ }
128
+
129
+ // ── Line comment: skip ──────────────────────────────────────
130
+ if (ch === '/' && nx === '/') {
131
+ i += 2;
132
+ while (i < code.length && code[i] !== '\n') i++;
133
+ continue;
134
+ }
135
+
136
+ // ── Regex literal: copy verbatim ────────────────────────────
137
+ if (ch === '/') {
138
+ if (_isRegexCtx(out)) {
139
+ out += ch; i++;
140
+ let inCC = false;
141
+ while (i < code.length) {
142
+ const rc = code[i];
143
+ if (rc === '\\') { out += rc + (code[i + 1] || ''); i += 2; continue; }
144
+ if (rc === '[') inCC = true;
145
+ if (rc === ']') inCC = false;
146
+ out += rc; i++;
147
+ if (rc === '/' && !inCC) {
148
+ while (i < code.length && /[gimsuy]/.test(code[i])) { out += code[i]; i++; }
149
+ break;
150
+ }
151
+ }
152
+ continue;
153
+ }
154
+ }
155
+
156
+ // ── Whitespace: collapse ────────────────────────────────────
157
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
158
+ while (i < code.length && (code[i] === ' ' || code[i] === '\t' || code[i] === '\n' || code[i] === '\r')) i++;
159
+ const before = out[out.length - 1];
160
+ const after = code[i];
161
+ if (_needsSpace(before, after)) out += ' ';
162
+ continue;
163
+ }
164
+
165
+ out += ch;
166
+ i++;
167
+ }
168
+
169
+ return out;
170
+ }
171
+
172
+ /** True when removing the space between a and b would change semantics. */
173
+ function _needsSpace(a, b) {
174
+ if (!a || !b) return false;
175
+ const idA = (a >= 'a' && a <= 'z') || (a >= 'A' && a <= 'Z') || (a >= '0' && a <= '9') || a === '_' || a === '$';
176
+ const idB = (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b === '_' || b === '$';
177
+ if (idA && idB) return true; // e.g. const x, return value
178
+ if (a === '+' && b === '+') return true; // prevent ++
179
+ if (a === '-' && b === '-') return true; // prevent --
180
+ if (a === '/' && (b === '/' || b === '*')) return true; // prevent // or /*
181
+ return false;
182
+ }
183
+
184
+ /** Heuristic: is the next '/' a regex start (vs division)? */
185
+ function _isRegexCtx(out) {
186
+ let end = out.length - 1;
187
+ while (end >= 0 && out[end] === ' ') end--;
188
+ if (end < 0) return true;
189
+ const last = out[end];
190
+ if ('=({[,;:!&|?~+-*/%^>'.includes(last)) return true;
191
+ const tail = out.substring(Math.max(0, end - 7), end + 1);
192
+ const kws = ['return', 'typeof', 'case', 'in', 'delete', 'void', 'throw', 'new'];
193
+ for (const kw of kws) {
194
+ if (tail.endsWith(kw)) {
195
+ const pos = end - kw.length;
196
+ if (pos < 0 || !((out[pos] >= 'a' && out[pos] <= 'z') || (out[pos] >= 'A' && out[pos] <= 'Z') || (out[pos] >= '0' && out[pos] <= '9') || out[pos] === '_' || out[pos] === '$')) return true;
197
+ }
198
+ }
199
+ return false;
95
200
  }
96
201
 
97
202
  // ---------------------------------------------------------------------------
Binary file