zero-query 0.1.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.
@@ -0,0 +1,800 @@
1
+ /**
2
+ * zQuery Component — Lightweight reactive component system
3
+ *
4
+ * Declarative components using template literals (no JSX, no build step).
5
+ * Proxy-based state triggers targeted re-renders via event delegation.
6
+ *
7
+ * Features:
8
+ * - Reactive state (auto re-render on mutation)
9
+ * - Template literals with full JS expression power
10
+ * - @event="method" syntax for event binding (delegated)
11
+ * - z-ref="name" for element references
12
+ * - z-model="stateKey" for two-way binding
13
+ * - Lifecycle hooks: init, mounted, updated, destroyed
14
+ * - Props passed via attributes
15
+ * - Scoped styles (inline or via styleUrl)
16
+ * - External templates via templateUrl (with {{expression}} interpolation)
17
+ * - External styles via styleUrl (fetched & scoped automatically)
18
+ * - Relative path resolution — templateUrl, styleUrl, and pages.dir
19
+ * resolve relative to the component file automatically
20
+ */
21
+
22
+ import { reactive } from './reactive.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Component registry & external resource cache
26
+ // ---------------------------------------------------------------------------
27
+ const _registry = new Map(); // name → definition
28
+ const _instances = new Map(); // element → instance
29
+ const _resourceCache = new Map(); // url → Promise<string>
30
+
31
+ // Unique ID counter
32
+ let _uid = 0;
33
+
34
+ /**
35
+ * Fetch and cache a text resource (HTML template or CSS file).
36
+ * @param {string} url — URL to fetch
37
+ * @returns {Promise<string>}
38
+ */
39
+ function _fetchResource(url) {
40
+ if (_resourceCache.has(url)) return _resourceCache.get(url);
41
+
42
+ // Resolve relative URLs against <base href> or origin root.
43
+ // This prevents SPA route paths (e.g. /docs/advanced) from
44
+ // breaking relative resource URLs like 'scripts/components/foo.css'.
45
+ let resolvedUrl = url;
46
+ if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
47
+ try {
48
+ const baseEl = document.querySelector('base');
49
+ const root = baseEl ? baseEl.href : (window.location.origin + '/');
50
+ resolvedUrl = new URL(url, root).href;
51
+ } catch { /* keep original */ }
52
+ }
53
+
54
+ const promise = fetch(resolvedUrl).then(res => {
55
+ if (!res.ok) throw new Error(`zQuery: Failed to load resource "${url}" (${res.status})`);
56
+ return res.text();
57
+ });
58
+ _resourceCache.set(url, promise);
59
+ return promise;
60
+ }
61
+
62
+ /**
63
+ * Convert a kebab-case id to Title Case.
64
+ * 'getting-started' → 'Getting Started'
65
+ * @param {string} id
66
+ * @returns {string}
67
+ */
68
+ function _titleCase(id) {
69
+ return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
70
+ }
71
+
72
+ /**
73
+ * Resolve a relative URL against a base.
74
+ *
75
+ * - If `base` is an absolute URL (http/https/file), resolve directly.
76
+ * - If `base` is a relative path string, resolve it against the page root
77
+ * (or <base href>) first, then resolve `url` against that.
78
+ * - If `base` is falsy, return `url` unchanged — _fetchResource's own
79
+ * fallback (page root / <base href>) handles it.
80
+ *
81
+ * @param {string} url — URL or relative path to resolve
82
+ * @param {string} [base] — auto-detected caller URL or explicit base path
83
+ * @returns {string}
84
+ */
85
+ function _resolveUrl(url, base) {
86
+ if (!base || !url || typeof url !== 'string') return url;
87
+ // Already absolute — nothing to do
88
+ if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
89
+ try {
90
+ if (base.includes('://')) {
91
+ // Absolute base (auto-detected module URL)
92
+ return new URL(url, base).href;
93
+ }
94
+ // Relative base string — resolve against page root first
95
+ const baseEl = document.querySelector('base');
96
+ const root = baseEl ? baseEl.href : (window.location.origin + '/');
97
+ const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
98
+ return new URL(url, absBase).href;
99
+ } catch {
100
+ return url;
101
+ }
102
+ }
103
+
104
+ // Capture the library's own script URL at load time for reliable filtering.
105
+ // This handles cases where the bundle is renamed (e.g., 'vendor.js').
106
+ let _ownScriptUrl;
107
+ try {
108
+ if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
109
+ _ownScriptUrl = document.currentScript.src.replace(/[?#].*$/, '');
110
+ }
111
+ } catch { /* ignored */ }
112
+
113
+ /**
114
+ * Detect the URL of the module that called $.component().
115
+ * Parses Error().stack to find the first frame outside the zQuery bundle.
116
+ * Returns the directory URL (with trailing slash) or undefined.
117
+ * @returns {string|undefined}
118
+ */
119
+ function _detectCallerBase() {
120
+ try {
121
+ const stack = new Error().stack || '';
122
+ const urls = stack.match(/(?:https?|file):\/\/[^\s\)]+/g) || [];
123
+ for (const raw of urls) {
124
+ // Strip line:col suffixes e.g. ":3:5" or ":12:1"
125
+ const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
126
+ // Skip the zQuery library itself — by filename pattern and captured URL
127
+ if (/zquery(\.min)?\.js$/i.test(url)) continue;
128
+ if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
129
+ // Return directory (strip filename, keep trailing slash)
130
+ return url.replace(/\/[^/]*$/, '/');
131
+ }
132
+ } catch { /* stack parsing unsupported — fall back silently */ }
133
+ return undefined;
134
+ }
135
+
136
+ /**
137
+ * Get a value from a nested object by dot-path.
138
+ * _getPath(obj, 'user.name') → obj.user.name
139
+ * @param {object} obj
140
+ * @param {string} path
141
+ * @returns {*}
142
+ */
143
+ function _getPath(obj, path) {
144
+ return path.split('.').reduce((o, k) => o?.[k], obj);
145
+ }
146
+
147
+ /**
148
+ * Set a value on a nested object by dot-path, walking through proxy layers.
149
+ * _setPath(proxy, 'user.name', 'Tony') → proxy.user.name = 'Tony'
150
+ * @param {object} obj
151
+ * @param {string} path
152
+ * @param {*} value
153
+ */
154
+ function _setPath(obj, path, value) {
155
+ const keys = path.split('.');
156
+ const last = keys.pop();
157
+ const target = keys.reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, obj);
158
+ if (target && typeof target === 'object') target[last] = value;
159
+ }
160
+
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Component class
164
+ // ---------------------------------------------------------------------------
165
+ class Component {
166
+ constructor(el, definition, props = {}) {
167
+ this._uid = ++_uid;
168
+ this._el = el;
169
+ this._def = definition;
170
+ this._mounted = false;
171
+ this._destroyed = false;
172
+ this._updateQueued = false;
173
+ this._listeners = [];
174
+
175
+ // Refs map
176
+ this.refs = {};
177
+
178
+ // Props (read-only from parent)
179
+ this.props = Object.freeze({ ...props });
180
+
181
+ // Reactive state
182
+ const initialState = typeof definition.state === 'function'
183
+ ? definition.state()
184
+ : { ...(definition.state || {}) };
185
+
186
+ this.state = reactive(initialState, () => {
187
+ if (!this._destroyed) this._scheduleUpdate();
188
+ });
189
+
190
+ // Bind all user methods to this instance
191
+ for (const [key, val] of Object.entries(definition)) {
192
+ if (typeof val === 'function' && !_reservedKeys.has(key)) {
193
+ this[key] = val.bind(this);
194
+ }
195
+ }
196
+
197
+ // Init lifecycle
198
+ if (definition.init) definition.init.call(this);
199
+ }
200
+
201
+ // Schedule a batched DOM update (microtask)
202
+ _scheduleUpdate() {
203
+ if (this._updateQueued) return;
204
+ this._updateQueued = true;
205
+ queueMicrotask(() => {
206
+ this._updateQueued = false;
207
+ if (!this._destroyed) this._render();
208
+ });
209
+ }
210
+
211
+ // Load external templateUrl / styleUrl if specified (once per definition)
212
+ //
213
+ // Relative paths are resolved automatically against the component file's
214
+ // own directory (auto-detected at registration time). You can override
215
+ // this with `base: 'some/path/'` on the definition.
216
+ //
217
+ // templateUrl accepts:
218
+ // - string → single template (used with {{expr}} interpolation)
219
+ // - string[] → array of URLs → indexed map via this.templates[0], …
220
+ // - { key: url, … } → named map → this.templates.key
221
+ //
222
+ // styleUrl accepts:
223
+ // - string → single stylesheet
224
+ // - string[] → array of URLs → all fetched & concatenated
225
+ //
226
+ // pages config (shorthand for multi-template + route-param page switching):
227
+ // pages: {
228
+ // dir: 'pages', // relative to component file (or base)
229
+ // param: 'section', // route param name → this.activePage
230
+ // default: 'getting-started', // fallback when param is absent
231
+ // ext: '.html', // file extension (default '.html')
232
+ // items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
233
+ // }
234
+ // Exposes this.pages (array of {id,label}), this.activePage (current id)
235
+ //
236
+ async _loadExternals() {
237
+ const def = this._def;
238
+ const base = def._base; // auto-detected or explicit
239
+
240
+ // ── Pages config ─────────────────────────────────────────────
241
+ if (def.pages && !def._pagesNormalized) {
242
+ const p = def.pages;
243
+ const ext = p.ext || '.html';
244
+ const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
245
+
246
+ // Normalize items → [{id, label}, …]
247
+ def._pages = (p.items || []).map(item => {
248
+ if (typeof item === 'string') return { id: item, label: _titleCase(item) };
249
+ return { id: item.id, label: item.label || _titleCase(item.id) };
250
+ });
251
+
252
+ // Auto-generate templateUrl object map
253
+ if (!def.templateUrl) {
254
+ def.templateUrl = {};
255
+ for (const { id } of def._pages) {
256
+ def.templateUrl[id] = `${dir}/${id}${ext}`;
257
+ }
258
+ }
259
+
260
+ def._pagesNormalized = true;
261
+ }
262
+
263
+ // ── External templates ──────────────────────────────────────
264
+ if (def.templateUrl && !def._templateLoaded) {
265
+ const tu = def.templateUrl;
266
+ if (typeof tu === 'string') {
267
+ def._externalTemplate = await _fetchResource(_resolveUrl(tu, base));
268
+ } else if (Array.isArray(tu)) {
269
+ const urls = tu.map(u => _resolveUrl(u, base));
270
+ const results = await Promise.all(urls.map(u => _fetchResource(u)));
271
+ def._externalTemplates = {};
272
+ results.forEach((html, i) => { def._externalTemplates[i] = html; });
273
+ } else if (typeof tu === 'object') {
274
+ const entries = Object.entries(tu);
275
+ // Pages config already resolved; plain objects still need resolving
276
+ const results = await Promise.all(
277
+ entries.map(([, url]) => _fetchResource(def._pagesNormalized ? url : _resolveUrl(url, base)))
278
+ );
279
+ def._externalTemplates = {};
280
+ entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
281
+ }
282
+ def._templateLoaded = true;
283
+ }
284
+
285
+ // ── External styles ─────────────────────────────────────────
286
+ if (def.styleUrl && !def._styleLoaded) {
287
+ const su = def.styleUrl;
288
+ if (typeof su === 'string') {
289
+ def._externalStyles = await _fetchResource(_resolveUrl(su, base));
290
+ } else if (Array.isArray(su)) {
291
+ const urls = su.map(u => _resolveUrl(u, base));
292
+ const results = await Promise.all(urls.map(u => _fetchResource(u)));
293
+ def._externalStyles = results.join('\n');
294
+ }
295
+ def._styleLoaded = true;
296
+ }
297
+ }
298
+
299
+ // Render the component
300
+ _render() {
301
+ // If externals haven't loaded yet, trigger async load then re-render
302
+ if ((this._def.templateUrl && !this._def._templateLoaded) ||
303
+ (this._def.styleUrl && !this._def._styleLoaded) ||
304
+ (this._def.pages && !this._def._pagesNormalized)) {
305
+ this._loadExternals().then(() => {
306
+ if (!this._destroyed) this._render();
307
+ });
308
+ return; // Skip this render — will re-render after load
309
+ }
310
+
311
+ // Expose multi-template map on instance (if available)
312
+ if (this._def._externalTemplates) {
313
+ this.templates = this._def._externalTemplates;
314
+ }
315
+
316
+ // Expose pages metadata and active page (derived from route param)
317
+ if (this._def._pages) {
318
+ this.pages = this._def._pages;
319
+ const pc = this._def.pages;
320
+ this.activePage = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
321
+ }
322
+
323
+ // Determine HTML content
324
+ let html;
325
+ if (this._def.render) {
326
+ // Inline render function takes priority
327
+ html = this._def.render.call(this);
328
+ } else if (this._def._externalTemplate) {
329
+ // External template with {{expression}} interpolation
330
+ html = this._def._externalTemplate.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
331
+ try {
332
+ return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
333
+ this.state.__raw || this.state,
334
+ this.props,
335
+ typeof window !== 'undefined' ? window.$ : undefined
336
+ );
337
+ } catch { return ''; }
338
+ });
339
+ } else {
340
+ html = '';
341
+ }
342
+
343
+ // Combine inline styles + external styles
344
+ const combinedStyles = [
345
+ this._def.styles || '',
346
+ this._def._externalStyles || ''
347
+ ].filter(Boolean).join('\n');
348
+
349
+ // Apply scoped styles on first render
350
+ if (!this._mounted && combinedStyles) {
351
+ const scopeAttr = `z-s${this._uid}`;
352
+ this._el.setAttribute(scopeAttr, '');
353
+ const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
354
+ return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
355
+ });
356
+ const styleEl = document.createElement('style');
357
+ styleEl.textContent = scoped;
358
+ styleEl.setAttribute('data-zq-component', this._def._name || '');
359
+ document.head.appendChild(styleEl);
360
+ this._styleEl = styleEl;
361
+ }
362
+
363
+ // ── Focus preservation for z-model ────────────────────────────
364
+ // Before replacing innerHTML, save focus state so we can restore
365
+ // cursor position after the DOM is rebuilt.
366
+ let _focusInfo = null;
367
+ const _active = document.activeElement;
368
+ if (_active && this._el.contains(_active)) {
369
+ const modelKey = _active.getAttribute?.('z-model');
370
+ if (modelKey) {
371
+ _focusInfo = {
372
+ key: modelKey,
373
+ start: _active.selectionStart,
374
+ end: _active.selectionEnd,
375
+ dir: _active.selectionDirection,
376
+ };
377
+ }
378
+ }
379
+
380
+ // Update DOM
381
+ this._el.innerHTML = html;
382
+
383
+ // Process directives
384
+ this._bindEvents();
385
+ this._bindRefs();
386
+ this._bindModels();
387
+
388
+ // Restore focus to z-model element after re-render
389
+ if (_focusInfo) {
390
+ const el = this._el.querySelector(`[z-model="${_focusInfo.key}"]`);
391
+ if (el) {
392
+ el.focus();
393
+ try {
394
+ if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
395
+ el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
396
+ }
397
+ } catch (_) { /* some input types don't support setSelectionRange */ }
398
+ }
399
+ }
400
+
401
+ // Mount nested components
402
+ mountAll(this._el);
403
+
404
+ if (!this._mounted) {
405
+ this._mounted = true;
406
+ if (this._def.mounted) this._def.mounted.call(this);
407
+ } else {
408
+ if (this._def.updated) this._def.updated.call(this);
409
+ }
410
+ }
411
+
412
+ // Bind @event="method" handlers via delegation
413
+ _bindEvents() {
414
+ // Clean up old delegated listeners
415
+ this._listeners.forEach(({ event, handler }) => {
416
+ this._el.removeEventListener(event, handler);
417
+ });
418
+ this._listeners = [];
419
+
420
+ // Find all elements with @event attributes
421
+ const allEls = this._el.querySelectorAll('*');
422
+ const eventMap = new Map(); // event → [{ selector, method, modifiers }]
423
+
424
+ allEls.forEach(child => {
425
+ [...child.attributes].forEach(attr => {
426
+ if (!attr.name.startsWith('@')) return;
427
+
428
+ const raw = attr.name.slice(1); // e.g. "click.prevent"
429
+ const parts = raw.split('.');
430
+ const event = parts[0];
431
+ const modifiers = parts.slice(1);
432
+ const methodExpr = attr.value;
433
+
434
+ // Give element a unique selector for delegation
435
+ if (!child.dataset.zqEid) {
436
+ child.dataset.zqEid = String(++_uid);
437
+ }
438
+ const selector = `[data-zq-eid="${child.dataset.zqEid}"]`;
439
+
440
+ if (!eventMap.has(event)) eventMap.set(event, []);
441
+ eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
442
+ });
443
+ });
444
+
445
+ // Register delegated listeners on the component root
446
+ for (const [event, bindings] of eventMap) {
447
+ const handler = (e) => {
448
+ for (const { selector, methodExpr, modifiers, el } of bindings) {
449
+ if (!e.target.closest(selector)) continue;
450
+
451
+ // Handle modifiers
452
+ if (modifiers.includes('prevent')) e.preventDefault();
453
+ if (modifiers.includes('stop')) e.stopPropagation();
454
+
455
+ // Parse method expression: "method" or "method(arg1, arg2)"
456
+ const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
457
+ if (match) {
458
+ const methodName = match[1];
459
+ const fn = this[methodName];
460
+ if (typeof fn === 'function') {
461
+ if (match[2] !== undefined) {
462
+ // Parse arguments (supports strings, numbers, state refs)
463
+ const args = match[2].split(',').map(a => {
464
+ a = a.trim();
465
+ if (a === '') return undefined;
466
+ if (a === 'true') return true;
467
+ if (a === 'false') return false;
468
+ if (a === 'null') return null;
469
+ if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
470
+ if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
471
+ // State reference
472
+ if (a.startsWith('state.')) return this.state[a.slice(6)];
473
+ return a;
474
+ }).filter(a => a !== undefined);
475
+ fn(e, ...args);
476
+ } else {
477
+ fn(e);
478
+ }
479
+ }
480
+ }
481
+ }
482
+ };
483
+ this._el.addEventListener(event, handler);
484
+ this._listeners.push({ event, handler });
485
+ }
486
+ }
487
+
488
+ // Bind z-ref="name" → this.refs.name
489
+ _bindRefs() {
490
+ this.refs = {};
491
+ this._el.querySelectorAll('[z-ref]').forEach(el => {
492
+ this.refs[el.getAttribute('z-ref')] = el;
493
+ });
494
+ }
495
+
496
+ // Bind z-model="stateKey" for two-way binding
497
+ //
498
+ // Supported elements: input (text, number, range, checkbox, radio, date, color, …),
499
+ // textarea, select (single & multiple), contenteditable
500
+ // Nested state keys: z-model="user.name" → this.state.user.name
501
+ // Modifiers (boolean attributes on the same element):
502
+ // z-lazy — listen on 'change' instead of 'input' (update on blur / commit)
503
+ // z-trim — trim whitespace before writing to state
504
+ // z-number — force Number() conversion regardless of input type
505
+ //
506
+ // Writes to reactive state so the rest of the UI stays in sync.
507
+ // Focus and cursor position are preserved in _render() via focusInfo.
508
+ //
509
+ _bindModels() {
510
+ this._el.querySelectorAll('[z-model]').forEach(el => {
511
+ const key = el.getAttribute('z-model');
512
+ const tag = el.tagName.toLowerCase();
513
+ const type = (el.type || '').toLowerCase();
514
+ const isEditable = el.hasAttribute('contenteditable');
515
+
516
+ // Modifiers
517
+ const isLazy = el.hasAttribute('z-lazy');
518
+ const isTrim = el.hasAttribute('z-trim');
519
+ const isNum = el.hasAttribute('z-number');
520
+
521
+ // Read current state value (supports dot-path keys)
522
+ const currentVal = _getPath(this.state, key);
523
+
524
+ // ── Set initial DOM value from state ────────────────────────
525
+ if (tag === 'input' && type === 'checkbox') {
526
+ el.checked = !!currentVal;
527
+ } else if (tag === 'input' && type === 'radio') {
528
+ el.checked = el.value === String(currentVal);
529
+ } else if (tag === 'select' && el.multiple) {
530
+ const vals = Array.isArray(currentVal) ? currentVal.map(String) : [];
531
+ [...el.options].forEach(opt => { opt.selected = vals.includes(opt.value); });
532
+ } else if (isEditable) {
533
+ if (el.textContent !== String(currentVal ?? '')) {
534
+ el.textContent = currentVal ?? '';
535
+ }
536
+ } else {
537
+ el.value = currentVal ?? '';
538
+ }
539
+
540
+ // ── Determine event type ────────────────────────────────────
541
+ const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
542
+ ? 'change'
543
+ : isEditable ? 'input' : 'input';
544
+
545
+ // ── Handler: read DOM → write to reactive state ─────────────
546
+ const handler = () => {
547
+ let val;
548
+ if (type === 'checkbox') val = el.checked;
549
+ else if (tag === 'select' && el.multiple) val = [...el.selectedOptions].map(o => o.value);
550
+ else if (isEditable) val = el.textContent;
551
+ else val = el.value;
552
+
553
+ // Apply modifiers
554
+ if (isTrim && typeof val === 'string') val = val.trim();
555
+ if (isNum || type === 'number' || type === 'range') val = Number(val);
556
+
557
+ // Write through the reactive proxy (triggers re-render).
558
+ // Focus + cursor are preserved automatically by _render().
559
+ _setPath(this.state, key, val);
560
+ };
561
+
562
+ el.addEventListener(event, handler);
563
+ });
564
+ }
565
+
566
+ // Programmatic state update (batch-friendly)
567
+ setState(partial) {
568
+ Object.assign(this.state, partial);
569
+ }
570
+
571
+ // Emit custom event up the DOM
572
+ emit(name, detail) {
573
+ this._el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable: true }));
574
+ }
575
+
576
+ // Destroy this component
577
+ destroy() {
578
+ if (this._destroyed) return;
579
+ this._destroyed = true;
580
+ if (this._def.destroyed) this._def.destroyed.call(this);
581
+ this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
582
+ this._listeners = [];
583
+ if (this._styleEl) this._styleEl.remove();
584
+ _instances.delete(this._el);
585
+ this._el.innerHTML = '';
586
+ }
587
+ }
588
+
589
+
590
+ // Reserved definition keys (not user methods)
591
+ const _reservedKeys = new Set([
592
+ 'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
593
+ 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
594
+ ]);
595
+
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Public API
599
+ // ---------------------------------------------------------------------------
600
+
601
+ /**
602
+ * Register a component
603
+ * @param {string} name — tag name (must contain a hyphen, e.g. 'app-counter')
604
+ * @param {object} definition — component definition
605
+ */
606
+ export function component(name, definition) {
607
+ if (!name.includes('-')) {
608
+ throw new Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
609
+ }
610
+ definition._name = name;
611
+
612
+ // Auto-detect the calling module's URL so that relative templateUrl,
613
+ // styleUrl, and pages.dir paths resolve relative to the component file.
614
+ // An explicit `base` string on the definition overrides auto-detection.
615
+ if (definition.base !== undefined) {
616
+ definition._base = definition.base; // explicit override
617
+ } else {
618
+ definition._base = _detectCallerBase();
619
+ }
620
+
621
+ _registry.set(name, definition);
622
+ }
623
+
624
+ /**
625
+ * Mount a component into a target element
626
+ * @param {string|Element} target — selector or element to mount into
627
+ * @param {string} componentName — registered component name
628
+ * @param {object} props — props to pass
629
+ * @returns {Component}
630
+ */
631
+ export function mount(target, componentName, props = {}) {
632
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
633
+ if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
634
+
635
+ const def = _registry.get(componentName);
636
+ if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
637
+
638
+ // Destroy existing instance
639
+ if (_instances.has(el)) _instances.get(el).destroy();
640
+
641
+ const instance = new Component(el, def, props);
642
+ _instances.set(el, instance);
643
+ instance._render();
644
+ return instance;
645
+ }
646
+
647
+ /**
648
+ * Scan a container for custom component tags and auto-mount them
649
+ * @param {Element} root — root element to scan (default: document.body)
650
+ */
651
+ export function mountAll(root = document.body) {
652
+ for (const [name, def] of _registry) {
653
+ const tags = root.querySelectorAll(name);
654
+ tags.forEach(tag => {
655
+ if (_instances.has(tag)) return; // Already mounted
656
+
657
+ // Extract props from attributes
658
+ const props = {};
659
+ [...tag.attributes].forEach(attr => {
660
+ if (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
661
+ // Try JSON parse for objects/arrays
662
+ try { props[attr.name] = JSON.parse(attr.value); }
663
+ catch { props[attr.name] = attr.value; }
664
+ }
665
+ });
666
+
667
+ const instance = new Component(tag, def, props);
668
+ _instances.set(tag, instance);
669
+ instance._render();
670
+ });
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Get the component instance for an element
676
+ * @param {string|Element} target
677
+ * @returns {Component|null}
678
+ */
679
+ export function getInstance(target) {
680
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
681
+ return _instances.get(el) || null;
682
+ }
683
+
684
+ /**
685
+ * Destroy a component at the given target
686
+ * @param {string|Element} target
687
+ */
688
+ export function destroy(target) {
689
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
690
+ const inst = _instances.get(el);
691
+ if (inst) inst.destroy();
692
+ }
693
+
694
+ /**
695
+ * Get the registry (for debugging)
696
+ */
697
+ export function getRegistry() {
698
+ return Object.fromEntries(_registry);
699
+ }
700
+
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // Global stylesheet loader
704
+ // ---------------------------------------------------------------------------
705
+ const _globalStyles = new Map(); // url → <link> element
706
+
707
+ /**
708
+ * Load one or more global stylesheets into <head>.
709
+ * Relative URLs resolve against the calling module's directory (auto-detected
710
+ * from the stack trace), just like component styleUrl paths.
711
+ * Returns a remove() handle so the caller can unload if needed.
712
+ *
713
+ * $.style('app.css') // critical by default
714
+ * $.style(['app.css', 'theme.css']) // multiple files
715
+ * $.style('/assets/global.css') // absolute — used as-is
716
+ * $.style('app.css', { critical: false }) // opt out of FOUC prevention
717
+ *
718
+ * Options:
719
+ * critical — (boolean, default true) When true, zQuery injects a tiny
720
+ * inline style that hides the page (`visibility: hidden`) and
721
+ * removes it once the stylesheet has loaded. This prevents
722
+ * FOUC (Flash of Unstyled Content) entirely — no special
723
+ * markup needed in the HTML file. Set to false to load
724
+ * the stylesheet without blocking paint.
725
+ * bg — (string, default '#0d1117') Background color applied while
726
+ * the page is hidden during critical load. Prevents a white
727
+ * flash on dark-themed apps. Only used when critical is true.
728
+ *
729
+ * Duplicate URLs are ignored (idempotent).
730
+ *
731
+ * @param {string|string[]} urls — stylesheet URL(s) to load
732
+ * @param {object} [opts] — options
733
+ * @param {boolean} [opts.critical=true] — hide page until loaded (prevents FOUC)
734
+ * @param {string} [opts.bg] — background color while hidden (default '#0d1117')
735
+ * @returns {{ remove: Function, ready: Promise }} — .remove() to unload, .ready resolves when loaded
736
+ */
737
+ export function style(urls, opts = {}) {
738
+ const callerBase = _detectCallerBase();
739
+ const list = Array.isArray(urls) ? urls : [urls];
740
+ const elements = [];
741
+ const loadPromises = [];
742
+
743
+ // Critical mode (default: true): inject a tiny inline <style> that hides the
744
+ // page and sets a background color. Fully self-contained — no markup needed
745
+ // in the HTML file. The style is removed once the sheet loads.
746
+ let _criticalStyle = null;
747
+ if (opts.critical !== false) {
748
+ _criticalStyle = document.createElement('style');
749
+ _criticalStyle.setAttribute('data-zq-critical', '');
750
+ _criticalStyle.textContent = `html{visibility:hidden!important;background:${opts.bg || '#0d1117'}}`;
751
+ document.head.insertBefore(_criticalStyle, document.head.firstChild);
752
+ }
753
+
754
+ for (let url of list) {
755
+ // Resolve relative paths against the caller's directory first,
756
+ // falling back to <base href> or origin root.
757
+ if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
758
+ url = _resolveUrl(url, callerBase);
759
+ }
760
+
761
+ if (_globalStyles.has(url)) {
762
+ elements.push(_globalStyles.get(url));
763
+ continue;
764
+ }
765
+
766
+ const link = document.createElement('link');
767
+ link.rel = 'stylesheet';
768
+ link.href = url;
769
+ link.setAttribute('data-zq-style', '');
770
+
771
+ const p = new Promise(resolve => {
772
+ link.onload = resolve;
773
+ link.onerror = resolve; // don't block forever on error
774
+ });
775
+ loadPromises.push(p);
776
+
777
+ document.head.appendChild(link);
778
+ _globalStyles.set(url, link);
779
+ elements.push(link);
780
+ }
781
+
782
+ // When all sheets are loaded, reveal the page if critical mode was used
783
+ const ready = Promise.all(loadPromises).then(() => {
784
+ if (_criticalStyle) {
785
+ _criticalStyle.remove();
786
+ }
787
+ });
788
+
789
+ return {
790
+ ready,
791
+ remove() {
792
+ for (const el of elements) {
793
+ el.remove();
794
+ for (const [k, v] of _globalStyles) {
795
+ if (v === el) { _globalStyles.delete(k); break; }
796
+ }
797
+ }
798
+ }
799
+ };
800
+ }