mount-observer 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -568,6 +568,104 @@ The handler automatically hoists templates that:
568
568
 
569
569
  </details>
570
570
 
571
+ ## Element Mount Configuration (EMC) Scripts
572
+
573
+ The `builtIns.emcScript` handler provides declarative element enhancement using `<script type="emc">` elements. EMC scripts combine mount observation with the [assign-gingerly](https://github.com/bahrus/assign-gingerly) enhancement system to apply behaviors, properties, and classes to elements as they mount.
574
+
575
+ **Why use EMC scripts?**
576
+
577
+ - Declaratively enhance elements without writing JavaScript
578
+ - Lazy load enhancement classes only when needed
579
+ - Automatically register and spawn enhancements
580
+ - Works with scoped custom element registries
581
+ - Supports attribute-based element matching
582
+ - Reuses enhancement definitions across multiple elements
583
+
584
+ **Basic usage:**
585
+
586
+ ```html
587
+ <!-- Define enhancement configuration -->
588
+ <script type="emc">
589
+ {
590
+ "matching": ".interactive",
591
+ "enhConfig": {
592
+ "spawn": "./my-enhancement.js",
593
+ "enhKey": "myEnhancement"
594
+ }
595
+ }
596
+ </script>
597
+
598
+ <!-- Elements matching the selector get enhanced -->
599
+ <div class="interactive">This will be enhanced</div>
600
+ <div class="interactive">This too</div>
601
+ ```
602
+
603
+ **External JSON configuration:**
604
+
605
+ ```html
606
+ <!-- Load configuration from external file -->
607
+ <script type="emc" src="./enh-config.json"></script>
608
+ ```
609
+
610
+ **With attribute matching:**
611
+
612
+ ```html
613
+ <script type="emc">
614
+ {
615
+ "matching": "button",
616
+ "enhConfig": {
617
+ "spawn": "./button-enhancement.js",
618
+ "enhKey": "fancyButton",
619
+ "withAttrs": {
620
+ "base": "variant"
621
+ }
622
+ }
623
+ }
624
+ </script>
625
+
626
+ <!-- Only buttons with variant="primary" get enhanced -->
627
+ <button variant="primary">Enhanced</button>
628
+ <button>Not enhanced</button>
629
+ ```
630
+
631
+ **How it works:**
632
+
633
+ 1. EMC script is parsed (inline JSON or external via `src`)
634
+ 2. Configuration is stored on `scriptElement.export`
635
+ 3. `resolved` event is dispatched
636
+ 4. Script ID is auto-generated as `${parentElement.localName}.${enhKey}` if not specified
637
+ 5. MountObserver watches for elements matching the configuration
638
+ 6. When element mounts:
639
+ - Checks if already enhanced (via `element.enh[enhKey]`)
640
+ - Registers enhancement class if not already registered
641
+ - Spawns enhancement instance via `element.enh.get(enhancementConfig)`
642
+
643
+ **Enhancement class example:**
644
+
645
+ ```javascript
646
+ // my-enhancement.js
647
+ export default class MyEnhancement {
648
+ constructor(element, ctx, initVals) {
649
+ this.element = element;
650
+ // Apply enhancement
651
+ this.element.classList.add('enhanced');
652
+ }
653
+
654
+ dispose() {
655
+ // Cleanup
656
+ this.element.classList.remove('enhanced');
657
+ }
658
+ }
659
+ ```
660
+
661
+ **Requirements:**
662
+
663
+ - Must import `ElementMountExtension.js` to enable `element.enh` property
664
+ - Must import `assign-gingerly/object-extension.js` for enhancement registry
665
+ - Enhancement classes should be constructors that accept `(element, ctx, initVals)`
666
+
667
+ [Implemented as EMCScript requirement](requirements/Done/EMCScript.md)
668
+
571
669
  ## Intra-Document HTML Includes with HTMLInclude
572
670
 
573
671
  The `builtIns.HTMLInclude` handler enables declarative HTML fragment reuse within a document using `<template src="#id">` syntax. Think of it as "constants for HTML" - define content once with an ID, then reference it multiple times throughout your document.
@@ -1,4 +1,53 @@
1
1
  import { EvtRt } from '../EvtRt.js';
2
+ /**
3
+ * Find a suitable HTMLElement class from a module.
4
+ * Checks the default export first, then searches all exports.
5
+ * @param module - The imported module
6
+ * @returns The HTMLElement class constructor
7
+ * @throws Error if no suitable class is found or multiple classes are found
8
+ */
9
+ function findSuitableClass(module) {
10
+ // Check default export first
11
+ const defaultExport = module.default;
12
+ if (defaultExport && extendsHTMLElement(defaultExport)) {
13
+ return defaultExport;
14
+ }
15
+ // Find all exports that extend HTMLElement
16
+ const htmlElementClasses = Object.values(module)
17
+ .filter(exp => typeof exp === 'function' && extendsHTMLElement(exp));
18
+ if (htmlElementClasses.length === 0) {
19
+ throw new Error('No suitable class found in module');
20
+ }
21
+ if (htmlElementClasses.length > 1) {
22
+ throw new Error('More than one class found in module');
23
+ }
24
+ return htmlElementClasses[0];
25
+ }
26
+ /**
27
+ * Check if a class extends HTMLElement.
28
+ * @param cls - The class to check
29
+ * @returns true if the class extends HTMLElement
30
+ */
31
+ function extendsHTMLElement(cls) {
32
+ try {
33
+ // Must be a function
34
+ if (typeof cls !== 'function') {
35
+ return false;
36
+ }
37
+ // Handle direct HTMLElement export
38
+ if (cls === HTMLElement) {
39
+ return true;
40
+ }
41
+ // Check if it has a prototype and extends HTMLElement
42
+ if (cls.prototype && cls.prototype instanceof HTMLElement) {
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
2
51
  export class DefineCustomElementHandler extends EvtRt {
3
52
  mount(mountedElement, MountConfig, context) {
4
53
  this.abort();
@@ -12,8 +61,8 @@ export class DefineCustomElementHandler extends EvtRt {
12
61
  if (customElements.get(tagName)) {
13
62
  return;
14
63
  }
15
- // Find suitable class
16
- const ElementClass = this.findSuitableClass(module);
64
+ // Find suitable class using shared utility
65
+ const ElementClass = findSuitableClass(module);
17
66
  // Validate that ElementClass is a constructor
18
67
  if (typeof ElementClass !== 'function') {
19
68
  throw new Error(`Found class is not a constructor: ${typeof ElementClass}`);
@@ -35,43 +84,6 @@ export class DefineCustomElementHandler extends EvtRt {
35
84
  define(tagName, ElementClass, mountedElement) {
36
85
  customElements.define(tagName, ElementClass);
37
86
  }
38
- findSuitableClass(module) {
39
- // Check default export first
40
- const defaultExport = module.default;
41
- if (defaultExport && this.extendsHTMLElement(defaultExport)) {
42
- return defaultExport;
43
- }
44
- // Find all exports that extend HTMLElement
45
- const htmlElementClasses = Object.values(module)
46
- .filter(exp => typeof exp === 'function' && this.extendsHTMLElement(exp));
47
- if (htmlElementClasses.length === 0) {
48
- throw new Error('No suitable class found in module');
49
- }
50
- if (htmlElementClasses.length > 1) {
51
- throw new Error('More than one class found in module');
52
- }
53
- return htmlElementClasses[0];
54
- }
55
- extendsHTMLElement(cls) {
56
- try {
57
- // Must be a function
58
- if (typeof cls !== 'function') {
59
- return false;
60
- }
61
- // Handle direct HTMLElement export
62
- if (cls === HTMLElement) {
63
- return true;
64
- }
65
- // Check if it has a prototype and extends HTMLElement
66
- if (cls.prototype && cls.prototype instanceof HTMLElement) {
67
- return true;
68
- }
69
- return false;
70
- }
71
- catch {
72
- return false;
73
- }
74
- }
75
87
  }
76
88
  /**
77
89
  * Handler for defining custom elements in scoped registries.
@@ -1,6 +1,61 @@
1
1
  import { EvtRt } from '../EvtRt.js';
2
2
  import { MountConfig, MountContext } from '../types/mount-observer/types.js';
3
3
 
4
+ /**
5
+ * Find a suitable HTMLElement class from a module.
6
+ * Checks the default export first, then searches all exports.
7
+ * @param module - The imported module
8
+ * @returns The HTMLElement class constructor
9
+ * @throws Error if no suitable class is found or multiple classes are found
10
+ */
11
+ function findSuitableClass(module: any): typeof HTMLElement {
12
+ // Check default export first
13
+ const defaultExport = module.default;
14
+
15
+ if (defaultExport && extendsHTMLElement(defaultExport)) {
16
+ return defaultExport;
17
+ }
18
+
19
+ // Find all exports that extend HTMLElement
20
+ const htmlElementClasses = Object.values(module)
21
+ .filter(exp => typeof exp === 'function' && extendsHTMLElement(exp));
22
+
23
+ if (htmlElementClasses.length === 0) {
24
+ throw new Error('No suitable class found in module');
25
+ }
26
+
27
+ if (htmlElementClasses.length > 1) {
28
+ throw new Error('More than one class found in module');
29
+ }
30
+
31
+ return htmlElementClasses[0] as typeof HTMLElement;
32
+ }
33
+
34
+ /**
35
+ * Check if a class extends HTMLElement.
36
+ * @param cls - The class to check
37
+ * @returns true if the class extends HTMLElement
38
+ */
39
+ function extendsHTMLElement(cls: any): boolean {
40
+ try {
41
+ // Must be a function
42
+ if (typeof cls !== 'function') {
43
+ return false;
44
+ }
45
+ // Handle direct HTMLElement export
46
+ if (cls === HTMLElement) {
47
+ return true;
48
+ }
49
+ // Check if it has a prototype and extends HTMLElement
50
+ if (cls.prototype && cls.prototype instanceof HTMLElement) {
51
+ return true;
52
+ }
53
+ return false;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
4
59
  export class DefineCustomElementHandler extends EvtRt {
5
60
  mount(mountedElement: Element, MountConfig: MountConfig, context: MountContext): void {
6
61
  this.abort();
@@ -17,8 +72,8 @@ export class DefineCustomElementHandler extends EvtRt {
17
72
  return;
18
73
  }
19
74
 
20
- // Find suitable class
21
- const ElementClass = this.findSuitableClass(module);
75
+ // Find suitable class using shared utility
76
+ const ElementClass = findSuitableClass(module);
22
77
 
23
78
  // Validate that ElementClass is a constructor
24
79
  if (typeof ElementClass !== 'function') {
@@ -43,49 +98,6 @@ export class DefineCustomElementHandler extends EvtRt {
43
98
  protected define(tagName: string, ElementClass: CustomElementConstructor, mountedElement: Element): void {
44
99
  customElements.define(tagName, ElementClass);
45
100
  }
46
-
47
- private findSuitableClass(module: any): typeof HTMLElement {
48
- // Check default export first
49
- const defaultExport = module.default;
50
-
51
- if (defaultExport && this.extendsHTMLElement(defaultExport)) {
52
- return defaultExport;
53
- }
54
-
55
- // Find all exports that extend HTMLElement
56
- const htmlElementClasses = Object.values(module)
57
- .filter(exp => typeof exp === 'function' && this.extendsHTMLElement(exp));
58
-
59
- if (htmlElementClasses.length === 0) {
60
- throw new Error('No suitable class found in module');
61
- }
62
-
63
- if (htmlElementClasses.length > 1) {
64
- throw new Error('More than one class found in module');
65
- }
66
-
67
- return htmlElementClasses[0] as typeof HTMLElement;
68
- }
69
-
70
- private extendsHTMLElement(cls: any): boolean {
71
- try {
72
- // Must be a function
73
- if (typeof cls !== 'function') {
74
- return false;
75
- }
76
- // Handle direct HTMLElement export
77
- if (cls === HTMLElement) {
78
- return true;
79
- }
80
- // Check if it has a prototype and extends HTMLElement
81
- if (cls.prototype && cls.prototype instanceof HTMLElement) {
82
- return true;
83
- }
84
- return false;
85
- } catch {
86
- return false;
87
- }
88
- }
89
101
  }
90
102
 
91
103
  /**
@@ -0,0 +1,178 @@
1
+ import { EvtRt } from '../EvtRt.js';
2
+ import { MountObserver } from '../MountObserver.js';
3
+ import '../ElementMountExtension.js';
4
+ import 'assign-gingerly/object-extension.js';
5
+ /**
6
+ * Handler for EMC (Element Mount Configuration) Script Elements.
7
+ * Processes script[type="emc"] elements to declaratively configure element enhancements.
8
+ *
9
+ * Supports two modes:
10
+ * 1. External JSON: <script type="emc" src="./config.json"></script>
11
+ * 2. Inline JSON: <script type="emc">{ "matching": "div", "enhConfig": {...} }</script>
12
+ *
13
+ * Unlike MountObserverScript, EMC scripts only support single config objects (not arrays).
14
+ */
15
+ export class EMCScriptHandler extends EvtRt {
16
+ // Static properties define default MountConfig constraints
17
+ static matching = 'script[type="emc"]';
18
+ static whereInstanceOf = HTMLScriptElement;
19
+ async mount(mountedElement, MountConfig, context) {
20
+ this.abort(); // Clean up event listeners (one-time operation)
21
+ const scriptElement = mountedElement;
22
+ let emcConfig = scriptElement.export;
23
+ if (!emcConfig) {
24
+ // Check if script has src attribute
25
+ const srcAttr = scriptElement.getAttribute('src');
26
+ if (srcAttr) {
27
+ // External JSON mode: import from src
28
+ try {
29
+ const module = await import(srcAttr, { with: { type: 'json' } });
30
+ emcConfig = module.default;
31
+ }
32
+ catch (error) {
33
+ throw new Error(`Failed to import JSON from '${srcAttr}': ${error instanceof Error ? error.message : String(error)}`);
34
+ }
35
+ }
36
+ else {
37
+ // Inline JSON mode: parse textContent
38
+ const jsonText = scriptElement.textContent?.trim();
39
+ if (!jsonText) {
40
+ throw new Error('Script element must have either src attribute or JSON content');
41
+ }
42
+ try {
43
+ emcConfig = JSON.parse(jsonText);
44
+ }
45
+ catch (error) {
46
+ throw new Error(`Failed to parse JSON content: ${error instanceof Error ? error.message : String(error)}`);
47
+ }
48
+ }
49
+ // Validate that config is an object (not array)
50
+ if (typeof emcConfig !== 'object' || emcConfig === null || Array.isArray(emcConfig)) {
51
+ throw new Error('EMC config must be an object (not an array)');
52
+ }
53
+ // Store the parsed config on the script element's export property
54
+ scriptElement.export = emcConfig;
55
+ // Dispatch resolved event
56
+ const { ResolvedEvent } = await import('../Events.js');
57
+ scriptElement.dispatchEvent(new ResolvedEvent(emcConfig));
58
+ }
59
+ // Validate EMC config has required properties
60
+ if (!emcConfig.enhConfig) {
61
+ throw new Error('EMC config must have enhConfig property');
62
+ }
63
+ const enhKey = emcConfig.enhConfig.enhKey;
64
+ if (!enhKey) {
65
+ throw new Error('EMC config enhConfig must have enhKey property');
66
+ }
67
+ // Set ID if not specified
68
+ if (!scriptElement.id && scriptElement.parentElement) {
69
+ scriptElement.id = `${scriptElement.parentElement.localName}.${enhKey}`;
70
+ }
71
+ // Construct MountConfig from EMC config
72
+ const mountConfig = await this.buildMountConfig(emcConfig);
73
+ // Create a MountObserver to watch for elements matching the config
74
+ const observer = new MountObserver(mountConfig);
75
+ // Store observer reference for cleanup
76
+ scriptElement.emcObserver = observer;
77
+ // Observe from the script element's parent or root node
78
+ const observeTarget = scriptElement.parentElement || scriptElement.getRootNode();
79
+ await observer.observe(observeTarget);
80
+ }
81
+ /**
82
+ * Build a MountConfig from an EMC config.
83
+ * Combines the matching selector with withAttrs if present.
84
+ */
85
+ async buildMountConfig(emcConfig) {
86
+ const { enhConfig, ...mountConfigBase } = emcConfig;
87
+ let matching = mountConfigBase.matching || '';
88
+ // If withAttrs is defined, use buildCSSQuery to combine with matching
89
+ if (enhConfig.withAttrs) {
90
+ const { buildCSSQuery } = await import('assign-gingerly/buildCSSQuery.js');
91
+ // Cast to any to avoid type mismatch with spawn property
92
+ const attrQuery = buildCSSQuery(enhConfig);
93
+ // Combine matching with attribute query
94
+ if (matching) {
95
+ matching = `${matching}${attrQuery}`;
96
+ }
97
+ else {
98
+ matching = attrQuery;
99
+ }
100
+ }
101
+ // Create the mount config with a custom handler
102
+ const mountConfig = {
103
+ ...mountConfigBase,
104
+ matching,
105
+ do: (mountedElement) => {
106
+ return this.handleMount(mountedElement, emcConfig);
107
+ }
108
+ };
109
+ return mountConfig;
110
+ }
111
+ /**
112
+ * Handle when an element mounts that matches the EMC config.
113
+ */
114
+ async handleMount(mountedElement, emcConfig) {
115
+ const enhKey = emcConfig.enhConfig.enhKey;
116
+ // Step 1: Check if element already has this enhancement
117
+ const enh = mountedElement.enh;
118
+ if (enh && enh[enhKey]) {
119
+ // Already enhanced, do nothing
120
+ return;
121
+ }
122
+ // Step 2: Get enhancement registry from the element's custom element registry
123
+ const customElementRegistry = mountedElement.customElementRegistry || customElements;
124
+ const enhancementRegistry = customElementRegistry.enhancementRegistry;
125
+ if (!enhancementRegistry) {
126
+ throw new Error('Enhancement registry not found on custom element registry');
127
+ }
128
+ // Check if enhancement is already registered using findByEnhKey method
129
+ let enhancementConfig = enhancementRegistry.findByEnhKey(enhKey);
130
+ // Step 3: If not registered, register it
131
+ if (!enhancementConfig) {
132
+ enhancementConfig = await this.registerEnhancement(emcConfig, enhancementRegistry);
133
+ }
134
+ // Step 4: Spawn enhancement instance
135
+ if (!enh) {
136
+ throw new Error('Element does not have enh property. Make sure ElementMountExtension is loaded.');
137
+ }
138
+ await enh.get(enhancementConfig);
139
+ }
140
+ /**
141
+ * Register an enhancement in the enhancement registry.
142
+ */
143
+ async registerEnhancement(emcConfig, enhancementRegistry) {
144
+ const { enhConfig } = emcConfig;
145
+ const { spawn } = enhConfig;
146
+ if (!spawn) {
147
+ throw new Error('EMC enhConfig must have spawn property');
148
+ }
149
+ // Step 3.1: Import the module
150
+ const module = await import(spawn);
151
+ // Get the enhancement class - it should be the default export or any exported class
152
+ let ElementClass = module.default;
153
+ // If no default export, try to find a suitable class
154
+ if (!ElementClass) {
155
+ // Look for any exported constructor function
156
+ for (const key of Object.keys(module)) {
157
+ if (typeof module[key] === 'function') {
158
+ ElementClass = module[key];
159
+ break;
160
+ }
161
+ }
162
+ }
163
+ if (!ElementClass) {
164
+ throw new Error(`No suitable class found in module ${spawn}`);
165
+ }
166
+ // Step 3.2: Construct enhancement config
167
+ const enhancementConfig = {
168
+ ...enhConfig,
169
+ spawn: ElementClass
170
+ };
171
+ // Step 3.3: Register in enhancement registry
172
+ enhancementRegistry.push(enhancementConfig);
173
+ return enhancementConfig;
174
+ }
175
+ }
176
+ // Register built-in handler
177
+ export const emc = 'builtIns.emcScript';
178
+ MountObserver.define(emc, EMCScriptHandler);