mnfst 0.5.180 → 0.5.181

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.
@@ -70,25 +70,34 @@ function initializeComboboxPlugin() {
70
70
  // Alpine binding attributes — never carried onto combobox-owned clones
71
71
  const isBinding = (n) => n[0] === ':' || n[0] === '@' || n.indexOf('x-') === 0;
72
72
 
73
+ // Rendered-snapshot clone: strip Alpine bindings (x-/:/@) from the whole subtree —
74
+ // they reference the source's x-for scope and would throw when Alpine walks the
75
+ // combobox-owned copy. Computed results (style, class, text) are baked in and survive.
76
+ function stripClone(root) {
77
+ Array.from(root.attributes).forEach(a => { if (isBinding(a.name)) root.removeAttribute(a.name); });
78
+ root.querySelectorAll('*').forEach(n => {
79
+ Array.from(n.attributes).forEach(a => { if (isBinding(a.name)) n.removeAttribute(a.name); });
80
+ });
81
+ return root;
82
+ }
83
+
73
84
  // Read options from a source element (datalist/select → option, menu → li)
74
85
  function readOptions(src) {
75
86
  if (!src) return [];
76
87
  if (src.tagName === 'MENU') {
77
88
  return Array.from(src.querySelectorAll('li')).map(li => {
78
- // The clone is a rendered snapshot: strip Alpine bindings (x-/:/@) from the
79
- // WHOLE subtree, not just the row they reference the source's x-for scope
80
- // and would throw when Alpine walks the combobox-owned copy. The computed
81
- // results (style, class, text) are already baked in and survive.
82
- const copy = li.cloneNode(true);
83
- copy.querySelectorAll('*').forEach(n => {
84
- Array.from(n.attributes).forEach(a => { if (isBinding(a.name)) n.removeAttribute(a.name); });
85
- });
89
+ const copy = stripClone(li.cloneNode(true));
90
+ // A [data-chip] element inside the row is the chip's rich content: the chip
91
+ // shows its rendered clone, and clicks are mirrored back to this live source
92
+ // fragment so author @click handlers run in their own x-for scope.
93
+ const chipSrc = li.querySelector('[data-chip]');
86
94
  return {
87
95
  value: li.dataset.value != null ? li.dataset.value : li.textContent.trim(),
88
96
  label: li.dataset.label || li.textContent.trim(),
89
97
  pattern: li.dataset.pattern || null,
90
98
  locked: li.hasAttribute('data-locked'),
91
99
  html: copy.innerHTML,
100
+ chip: chipSrc ? (n => ({ node: n, src: chipSrc, sig: n.outerHTML }))(stripClone(chipSrc.cloneNode(true))) : null,
92
101
  // Carry the row's own attributes into the option, minus combobox-managed ones.
93
102
  attrs: Array.from(li.attributes)
94
103
  .filter(a => { const n = a.name; return n !== 'role' && n !== 'id' && n !== 'aria-selected' && !isBinding(n); })
@@ -98,7 +107,9 @@ function initializeComboboxPlugin() {
98
107
  }
99
108
  return Array.from(src.querySelectorAll('option')).map(o => ({
100
109
  value: o.value || o.textContent.trim(),
101
- label: o.textContent.trim() || o.value,
110
+ // o.label is the native `label` IDL prop: the `label` attribute when set, else
111
+ // the option's text — so <option value="SE" label="Sweden"> works natively.
112
+ label: (o.label || '').trim() || o.value,
102
113
  pattern: o.getAttribute('data-pattern') || null,
103
114
  locked: o.hasAttribute('data-locked'),
104
115
  html: null
@@ -246,6 +257,17 @@ function initializeComboboxPlugin() {
246
257
  if (!editorNone) el.removeAttribute('placeholder');
247
258
  if (name) el.removeAttribute('name'); // hidden inputs carry the value(s)
248
259
 
260
+ // A wrapping <label> with no `for` targets its FIRST labelable descendant —
261
+ // that's the first chip's remove button, not the editor. Hovering the label then
262
+ // hover-lights that ×, and clicking the label (or a chip's text) activates it,
263
+ // deleting chips. Bind the label to the editor explicitly; clicks on interactive
264
+ // chip content (the ×, or any links/handlers) still land on it, not the label.
265
+ const ownerLabel = wrap.closest('label');
266
+ if (ownerLabel && !ownerLabel.hasAttribute('for')) {
267
+ if (!el.id) el.id = 'combobox-editor-' + rand();
268
+ ownerLabel.setAttribute('for', el.id);
269
+ }
270
+
249
271
  // Live region for add/remove announcements
250
272
  const live = document.createElement('span');
251
273
  live.setAttribute('role', 'status');
@@ -572,11 +594,47 @@ function initializeComboboxPlugin() {
572
594
  chip.className = 'combobox-chip';
573
595
  chip.dataset.value = s.value;
574
596
  const label = document.createElement('span');
575
- label.textContent = s.label;
597
+ setChipContent(label, s);
576
598
  chip.appendChild(label);
577
599
  applyLock(chip, s.value, s.label);
578
600
  return chip;
579
601
  }
602
+ // Chip content: the option's [data-chip] fragment when present (rendered clone,
603
+ // refreshed in place on reread — updating innerHTML never re-inserts the chip
604
+ // node, so the WebKit focused-insert collapse can't trigger), else the text
605
+ // label. Links in the clone navigate natively (computed href carries); other
606
+ // clicks are mirrored onto the same element in the LIVE source fragment, where
607
+ // the author's @click runs in its own x-for scope.
608
+ function setChipContent(span, s) {
609
+ const o = options.find(o => String(o.value).toLowerCase() === String(s.value).toLowerCase());
610
+ const rich = o && o.chip;
611
+ const sig = rich ? o.chip.sig : 't:' + s.label;
612
+ if (span.__cbSig === sig) return;
613
+ span.__cbSig = sig;
614
+ if (!rich) { span.textContent = s.label; span.__cbSrc = null; return; }
615
+ span.innerHTML = '';
616
+ span.appendChild(o.chip.node.cloneNode(true));
617
+ span.__cbSrc = o.chip.src;
618
+ if (span.__cbDelegated) return;
619
+ span.__cbDelegated = true;
620
+ span.addEventListener('click', (e) => {
621
+ const srcRoot = span.__cbSrc;
622
+ if (!srcRoot || !srcRoot.isConnected) return;
623
+ if (e.target.closest && e.target.closest('a[href]')) return; // native nav owns it
624
+ const cloneRoot = span.firstElementChild;
625
+ let n = e.target === span ? cloneRoot : e.target;
626
+ const path = [];
627
+ while (n && n !== cloneRoot) {
628
+ const p = n.parentElement;
629
+ if (!p) return;
630
+ path.unshift(Array.prototype.indexOf.call(p.children, n));
631
+ n = p;
632
+ }
633
+ let t = srcRoot;
634
+ for (const i of path) { t = t.children[i]; if (!t) return; }
635
+ t.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
636
+ });
637
+ }
580
638
  // Add/drop the × to match locked state. Re-run from render() so a reactive
581
639
  // `locked` change toggles existing chips too.
582
640
  function applyLock(chip, value, label) {
@@ -615,7 +673,11 @@ function initializeComboboxPlugin() {
615
673
  const key = norm(s.value);
616
674
  const node = have.get(key);
617
675
  if (!node) { const n = makeChip(s); have.set(key, n); wrap.insertBefore(n, el); }
618
- else applyLock(node, s.value, s.label);
676
+ else {
677
+ applyLock(node, s.value, s.label);
678
+ const sp = node.querySelector(':scope > span');
679
+ if (sp) setChipContent(sp, s); // in-place refresh; node identity kept
680
+ }
619
681
  });
620
682
  // Reorder only when order differs — needless re-insertion collapses chips
621
683
  // in WebKit; the common append path (order matches) skips this.
@@ -802,11 +864,11 @@ function initializeComboboxPlugin() {
802
864
  if (menu && menu.matches(':popover-open')) filter();
803
865
  };
804
866
  const mo = new MutationObserver(reread);
805
- // childList/characterData: x-for row edits. attributes: bound data-* toggles.
806
- mo.observe(src, {
807
- childList: true, subtree: true, characterData: true,
808
- attributes: true, attributeFilter: ['data-value', 'data-label', 'data-locked', 'data-pattern']
809
- });
867
+ // childList/characterData: x-for row edits. attributes unfiltered: ANY bound
868
+ // attribute inside a row (style, class, data-*) changes the rendered snapshot
869
+ // the menu and rich chips clone, so all must re-trigger. Mutations coalesce
870
+ // per microtask, so a state flush costs one reread.
871
+ mo.observe(src, { childList: true, subtree: true, characterData: true, attributes: true });
810
872
  if (cleanup) cleanup(() => mo.disconnect());
811
873
  }
812
874
 
@@ -7,7 +7,7 @@
7
7
  "manifest.code.js": "sha384-G3MNfvQRWWY+gm4SGGrSsgQbDeil9MP+ON2DTk6Rcq9Nsex8woHMDt5EJHrFH0Nj",
8
8
  "manifest.color.js": "sha384-6Rv3LxyTcZNjrhtayQfqRdCx0uSZ4BiEbgEI98I62eTvp8Aw7LBIoNJ0Je1oktwL",
9
9
  "manifest.colorpicker.js": "sha384-N5ezEwgKrDodV4YdURxhdyZKmg1n/QhqvmAC4SEG03mw1pEAZAA8Gphp+NLAOxts",
10
- "manifest.combobox.js": "sha384-fE/SS0hPsSoCqn2x3H9u1HLY8dZ38PI/NCzrDpqL7+11QPOehG/f0f5MMGQqhBle",
10
+ "manifest.combobox.js": "sha384-J1zXXanlzsfoYJsRdz41DJD168S+20oT+lIEpUJ4UOjwjMcy/t+2GwwVjfSSNOlD",
11
11
  "manifest.components.js": "sha384-eZDib+RFdwd9mOUdi6o53eNS0LBlEEkZPDegxtkAcE+GrGIFxsuB4O0ymfixGNfM",
12
12
  "manifest.data.js": "sha384-3lLvGnqcQr4Ium80MdaQTr8RytjVbs5rnxd0q41BF3ppcoQccMFAcvgFSyZP/0sl",
13
13
  "manifest.datepicker.js": "sha384-52BBZdlLN+EATiUorwTEQOWGJZiSNu0voy9q+WQxxWpUeYsGGkuS0QPPXGx2akxG",
@@ -30,5 +30,5 @@
30
30
  "manifest.url.parameters.js": "sha384-+ZOsgPgbFmKOZsReC1EiH8PsZwvr2nC/duhzIzFf70xrbRrLsr926sLFW0M+BXtN",
31
31
  "manifest.utilities.js": "sha384-yLEqEbd1FncqswrtrcTO7KrIEK4iP0ZvqCi64XLI7D77/L0YiJbjLdLGGg6l7rgN",
32
32
  "manifest.virtual.js": "sha384-c194pXD0Ld48QJ+HPVNibvbn54/ETJRuYhUOWesEBjtOIQcQMIKo0DnCyQMvDlLg",
33
- "manifest.js": "sha384-FLz68HEE8hEVLlAsoAX2+5cWBrhS5+B24rdJy5obXtP5U5bJKCswjbIQL8MpDUFZ"
33
+ "manifest.js": "sha384-2XTZDu5M3icGSRvAVLeLQfQqwJQvzeOfW0yMRDqVa8kxhTqfBwA17RBBYOUY3d9a"
34
34
  }
package/lib/manifest.js CHANGED
@@ -466,7 +466,7 @@
466
466
  ))) {
467
467
  plugins.push('appwrite-data');
468
468
  }
469
- if (manifest.appwrite?.presence) {
469
+ if (manifest.appwrite?.presence || manifest.appwrite?.presences) {
470
470
  plugins.push('appwrite-presences');
471
471
  }
472
472
  return plugins;
@@ -481,14 +481,54 @@
481
481
  }
482
482
 
483
483
  // Detect the chat plugin from manifest.json content.
484
- // Opt-in / auto-loaded when an `ai` (or `chat`) config block is present.
484
+ // Opt-in / auto-loaded when an `ai` (or `chat`) entry is present — an
485
+ // object block or bare `true` (custom-adapter projects have no config).
485
486
  function detectChatPlugins(manifest) {
486
487
  if (!manifest || typeof manifest !== 'object') return [];
487
- if ((manifest.ai && typeof manifest.ai === 'object') ||
488
- (manifest.chat && typeof manifest.chat === 'object')) return ['chat'];
488
+ if (manifest.ai || manifest.chat) return ['chat'];
489
489
  return [];
490
490
  }
491
491
 
492
+ // Detect plugins from markup usage — magics written in Alpine expressions
493
+ // with no declarative trigger (e.g. $chat driven by custom adapters).
494
+ // Runs at DCL, before Alpine boots: scans element attributes (recursing
495
+ // into template content, which the parser detaches) and inline scripts.
496
+ // Rendered text is deliberately not scanned — code samples on a page
497
+ // must not pull plugins in.
498
+ const USAGE_PLUGINS = [
499
+ { magic: '$chat', plugin: 'chat' },
500
+ { magic: '$presence', plugin: 'appwrite-presences' }
501
+ ];
502
+ function detectUsagePlugins(alreadyLoaded) {
503
+ const wanted = USAGE_PLUGINS.filter(u => !alreadyLoaded.includes(u.plugin));
504
+ if (wanted.length === 0 || typeof document === 'undefined') return [];
505
+ const found = new Set();
506
+ const done = () => found.size === wanted.length;
507
+
508
+ for (const s of document.querySelectorAll('script:not([src])')) {
509
+ const t = s.textContent || '';
510
+ for (const u of wanted) if (!found.has(u.plugin) && t.includes(u.magic)) found.add(u.plugin);
511
+ if (done()) return [...found];
512
+ }
513
+
514
+ const scan = (root) => {
515
+ for (const el of root.querySelectorAll('*')) {
516
+ for (const attr of el.attributes || []) {
517
+ const v = attr.value;
518
+ if (!v || v.indexOf('$') === -1) continue;
519
+ for (const u of wanted) if (!found.has(u.plugin) && v.includes(u.magic)) found.add(u.plugin);
520
+ if (done()) return;
521
+ }
522
+ if (el.tagName === 'TEMPLATE' && el.content) {
523
+ scan(el.content);
524
+ if (done()) return;
525
+ }
526
+ }
527
+ };
528
+ scan(document);
529
+ return [...found];
530
+ }
531
+
492
532
  // Detect the native umbrella plugin. Auto-loaded inside a Capacitor container
493
533
  // (window.Capacitor present) or when a `native` config block opts in on the web.
494
534
  function detectNativePlugins(manifest) {
@@ -520,12 +560,25 @@
520
560
  // Override: custom CDN fallback chain (comma-separated origins).
521
561
  const cdn = script.getAttribute('data-cdn');
522
562
 
563
+ // `data-plugins="a,b"` replaces the default set; a `+` prefix is additive
564
+ // (`data-plugins="+chat"` = defaults plus chat) — needed for plugins with
565
+ // no manifest.json trigger, e.g. chat driven by custom adapters only.
523
566
  let pluginList = [];
524
- const deriveFromManifest = !plugins;
567
+ let extraPlugins = [];
568
+ let deriveFromManifest = !plugins;
525
569
 
526
570
  if (plugins) {
527
- // Explicit declaration - load only specified plugins (core + Appwrite)
528
- pluginList = plugins.split(',').map(p => p.trim()).filter(p => p);
571
+ const entries = plugins.split(',').map(p => p.trim()).filter(p => p);
572
+ extraPlugins = entries.filter(p => p.startsWith('+')).map(p => p.slice(1));
573
+ const explicit = entries.filter(p => !p.startsWith('+'));
574
+ if (explicit.length > 0) {
575
+ pluginList = [...explicit, ...extraPlugins];
576
+ } else {
577
+ // Only additive entries: keep the default derive-from-manifest
578
+ // behavior and append the extras once the manifest is inspected.
579
+ deriveFromManifest = true;
580
+ pluginList = AVAILABLE_PLUGINS.slice();
581
+ }
529
582
  } else {
530
583
  // Default: start with all core plugins; loader will trim by manifest when manifest is available
531
584
  pluginList = AVAILABLE_PLUGINS.slice();
@@ -542,6 +595,7 @@
542
595
 
543
596
  return {
544
597
  plugins: pluginList,
598
+ extraPlugins,
545
599
  deriveFromManifest,
546
600
  tailwind,
547
601
  version,
@@ -598,7 +652,7 @@
598
652
 
599
653
  const MANIFEST_DEPENDENT_PLUGINS = [
600
654
  'data', 'localization', 'components',
601
- 'appwrite-auth', 'appwrite-data', 'appwrite-presences', 'payments'
655
+ 'appwrite-auth', 'appwrite-data', 'appwrite-presences', 'payments', 'chat'
602
656
  ];
603
657
  const manifestUrl = (document.querySelector('link[rel="manifest"]')?.getAttribute('href')) || '/manifest.json';
604
658
 
@@ -664,7 +718,7 @@
664
718
  const paymentsPlugins = detectPaymentsPlugins(manifest);
665
719
  const chatPlugins = detectChatPlugins(manifest);
666
720
  const nativePlugins = detectNativePlugins(manifest);
667
- pluginsToLoad = resolveDependencies([...corePlugins, ...appwritePlugins, ...paymentsPlugins, ...chatPlugins, ...nativePlugins]);
721
+ pluginsToLoad = resolveDependencies([...corePlugins, ...appwritePlugins, ...paymentsPlugins, ...chatPlugins, ...nativePlugins, ...(config.extraPlugins || [])]);
668
722
  } else {
669
723
  const needsManifest = config.plugins.some(p => MANIFEST_DEPENDENT_PLUGINS.includes(p));
670
724
  if (needsManifest) {
@@ -698,7 +752,24 @@
698
752
  window.ManifestComponentsRegistry.manifest = manifest;
699
753
  }
700
754
  }
701
- loadAlpine(alpineUrlCandidates(config.alpine));
755
+ // Usage sniff: Alpine is DCL-gated anyway, so the parsed document can
756
+ // be checked for magics with no declarative trigger and the missing
757
+ // plugins fetched before Alpine boots. Derive path only — an explicit
758
+ // data-plugins list is an intentional constraint, not a default.
759
+ whenDomReady(async () => {
760
+ if (config.deriveFromManifest) {
761
+ try {
762
+ const used = detectUsagePlugins(pluginsToLoad);
763
+ if (used.length > 0) {
764
+ const late = resolveDependencies(used).filter(p => !pluginsToLoad.includes(p));
765
+ await Promise.all(late.map(p => addScript(p, config.version).catch(error => {
766
+ console.warn(`[Manifest Loader] Failed to load plugin ${p}:`, error);
767
+ })));
768
+ }
769
+ } catch (_) { /* sniffing must never block Alpine */ }
770
+ }
771
+ loadAlpine(alpineUrlCandidates(config.alpine));
772
+ });
702
773
  };
703
774
 
704
775
  loadPlugins();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.180",
3
+ "version": "0.5.181",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",