mount-observer-script-element 0.0.1

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/MOSE.ts ADDED
@@ -0,0 +1,228 @@
1
+ import { getHighestCERNode } from './getHighestCERNode.js';
2
+ import { MountObserver } from 'mount-observer/MountObserver.js';
3
+ import { MountEvent, mountEventName } from 'mount-observer/Events.js'
4
+
5
+ /**
6
+ * Type for a constructor that can be extended
7
+ */
8
+ type Constructor<T = HTMLElement> = new (...args: any[]) => T;
9
+
10
+ /**
11
+ * Symbol to track if MountObserver has been set up for an element
12
+ */
13
+ const MOUNT_OBSERVER_SETUP = Symbol.for('cteH9dMG-UWwxVaMwFgvQA');
14
+
15
+ /**
16
+ * MOSE (Mount Observer Script Element) Mixin
17
+ *
18
+ * This mixin adds functionality to:
19
+ * 1. Check for duplicate custom element registrations within the same custom element registry scope
20
+ * 2. Monitor for <script type="mountobserver"> elements and apply their configurations
21
+ *
22
+ * @param Base - The base class to extend
23
+ * @returns Extended class with MOSE functionality
24
+ */
25
+ export function MOSE<T extends Constructor<HTMLElement>>(Base: T) {
26
+ return class extends Base {
27
+ #mountObserver: MountObserver | undefined;
28
+
29
+ constructor(...args: any[]) {
30
+ super(...args);
31
+ this.#checkForDuplicateRegistration();
32
+ this.#setupMountObserver();
33
+ }
34
+
35
+ #checkForDuplicateRegistration() {
36
+ // Get the tag name of this element
37
+ // const tagName = this.tagName.toLowerCase();
38
+ const {localName} = this;
39
+
40
+ // Find the highest node with the same custom element registry
41
+ const highestCERNode = getHighestCERNode(this) as DocumentFragment;
42
+
43
+ if (!highestCERNode) {
44
+ return;
45
+ }
46
+
47
+ // Get the custom element registry for this scope
48
+ // const registry = (highestCERNode as any).customElementRegistry as CustomElementRegistry | undefined;
49
+
50
+ // // If there's no custom registry, use the global one
51
+ // const targetRegistry = registry || customElements;
52
+
53
+ // Check if an element with this name is already defined in this registry
54
+ try {
55
+ const existingTags =Array.from(highestCERNode.querySelectorAll(localName)).filter(x => x !== this);
56
+
57
+ if (existingTags.length > 0) {
58
+ throw new Error(
59
+ `Custom element "${localName}" is already defined in this custom element registry scope.`
60
+ );
61
+ }
62
+ } catch (error) {
63
+ // If get() throws, it means the element isn't registered yet, which is fine
64
+ // But if it's our error, re-throw it
65
+ if (error instanceof Error && error.message.includes('already defined')) {
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ // Copy mountobserver script elements from container
71
+ this.#getContainerMOSEs(highestCERNode);
72
+ }
73
+
74
+ #getContainerMOSEs(highestCERNode: Node) {
75
+ // Step 1: Don't do anything if highestCERNode is the document root
76
+ if (highestCERNode === document) {
77
+ return;
78
+ }
79
+
80
+ // Step 2: Get parent element or host
81
+ let parentNode: Element | null = null;
82
+ if (highestCERNode instanceof Element) {
83
+ parentNode = highestCERNode.parentElement;
84
+ } else if (highestCERNode instanceof ShadowRoot) {
85
+ parentNode = highestCERNode.host;
86
+ }
87
+
88
+ if (!parentNode) {
89
+ return;
90
+ }
91
+
92
+ // Step 3: Find the highestCERNode of the parent
93
+ const parentHighestCERNode = getHighestCERNode(parentNode);
94
+
95
+ // Find parent custom element with same localName
96
+ if (!parentHighestCERNode || !('querySelector' in parentHighestCERNode)) {
97
+ return;
98
+ }
99
+
100
+ const parentCE = (parentHighestCERNode as DocumentFragment).querySelector(this.localName);
101
+ if (!parentCE) {
102
+ return;
103
+ }
104
+
105
+ // If highestCERNode contains parentCE, exit
106
+ if ((highestCERNode as DocumentFragment).contains?.(parentCE)) {
107
+ return;
108
+ }
109
+
110
+ // Clone and append script elements
111
+ this.#cloneAndAppendScripts(parentCE);
112
+
113
+ // Add event listener for future mount events
114
+ parentCE.addEventListener(mountEventName, (e: Event) => {
115
+ const mountedElement = (e as MountEvent).mountedElement;
116
+ if (mountedElement instanceof HTMLScriptElement && mountedElement.type === 'mountobserver') {
117
+ this.#cloneAndAppendScripts(parentCE);
118
+ }
119
+ });
120
+ }
121
+
122
+ #cloneAndAppendScripts(sourceElement: Element) {
123
+ const scripts = Array.from(sourceElement.querySelectorAll('script[type="mountobserver"]')) as HTMLScriptElement[];
124
+
125
+ // Get exclude value from property or attribute
126
+ const exclude = (this as any).exclude ?? this.getAttribute('exclude');
127
+
128
+ for (const script of scripts) {
129
+ // Check if script matches exclude criteria
130
+ if (exclude && script.matches(exclude)) {
131
+ continue;
132
+ }
133
+
134
+ const src = script.getAttribute('src');
135
+
136
+ // Check if we already have a script with the same src
137
+ const existingScripts = Array.from(this.querySelectorAll('script[type="mountobserver"]')) as HTMLScriptElement[];
138
+ const alreadyExists = existingScripts.some(existing => {
139
+ const existingSrc = existing.getAttribute('src');
140
+ return existingSrc === src;
141
+ });
142
+
143
+ if (!alreadyExists) {
144
+ const clonedScript = script.cloneNode(true) as HTMLScriptElement;
145
+ this.appendChild(clonedScript);
146
+ }
147
+ }
148
+ }
149
+
150
+ async #setupMountObserver() {
151
+ // Find the highest node with the same custom element registry
152
+ const highestCERNode = getHighestCERNode(this);
153
+
154
+ if (!highestCERNode) {
155
+ return;
156
+ }
157
+
158
+ // Check if MountObserver has already been set up for this element
159
+ const existingObserver = (highestCERNode as any)[MOUNT_OBSERVER_SETUP];
160
+ if (existingObserver) {
161
+ // Subscribe to the existing observer's mount event and re-dispatch from this element
162
+ existingObserver.addEventListener(mountEventName, (e: MountEvent) => {
163
+ const {mountedElement} = e;
164
+ if(this.contains(mountedElement)){
165
+ this.dispatchEvent(e);
166
+ }
167
+ });
168
+ return;
169
+ }
170
+
171
+ // Set up MountObserver to watch for <script type="mountobserver"> elements
172
+ this.#mountObserver = new MountObserver({
173
+ whereElementMatches: 'script[type="mountobserver"]',
174
+ do: async (scriptElement: Element) => {
175
+ await this.#processScriptElement(scriptElement as HTMLScriptElement, highestCERNode);
176
+ }
177
+ });
178
+
179
+ // Mark that we've set up the MountObserver for this element
180
+ (highestCERNode as any)[MOUNT_OBSERVER_SETUP] = this.#mountObserver;
181
+
182
+ await this.#mountObserver.observe(highestCERNode);
183
+ }
184
+
185
+ async #processScriptElement(scriptElement: HTMLScriptElement, rootNode: Node) {
186
+ let config: any = {};
187
+
188
+ // Step 1: Check if script has src attribute and load JSON
189
+ const src = scriptElement.getAttribute('src')
190
+ if (src) {
191
+ try {
192
+ const response = await import(src, {with: {type: 'json'}});
193
+ config = structuredClone(response.default);
194
+ } catch (error) {
195
+ console.error(`Failed to load JSON from ${scriptElement.src}:`, error);
196
+ return;
197
+ }
198
+ }
199
+
200
+ // Step 2: If innerHTML is non-trivial, parse and merge it
201
+ const innerHTML = scriptElement.innerHTML.trim();
202
+ if (innerHTML) {
203
+ try {
204
+ const parsedJSON = JSON.parse(innerHTML);
205
+
206
+ // Step 3: Import assignGingerly and merge
207
+ const { assignGingerly } = await import('assign-gingerly/assignGingerly.js');
208
+ config = assignGingerly(config, parsedJSON, {
209
+ registry: (<any>scriptElement).customElementRegistry.assignGingerlyRegistry
210
+ });
211
+ } catch (error) {
212
+ console.error('Failed to parse script innerHTML as JSON:', error);
213
+ return;
214
+ }
215
+ }
216
+
217
+ // Step 4: Apply MountObserver with the merged config
218
+ if (Object.keys(config).length > 0) {
219
+ try {
220
+ const observer = new MountObserver(config);
221
+ observer.observe(rootNode);
222
+ } catch (error) {
223
+ console.error('Failed to create MountObserver with config:', error);
224
+ }
225
+ }
226
+ }
227
+ };
228
+ }
package/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # mount-observer-script-element
2
+
3
+ A TypeScript mixin (MOSE) that enables declarative configuration of [MountObserver](https://www.npmjs.com/package/mount-observer) instances through `<script type="mountobserver">` elements, with support for Chrome's Scoped Custom Element Registries.
4
+
5
+ ## Overview
6
+
7
+ The MOSE (Mount Observer Script Element) mixin provides:
8
+
9
+ 1. **Scoped Custom Element Registry Support**: Works with Chrome's scoped custom element registries
10
+ 2. **Declarative MountObserver Configuration**: Configure MountObserver instances using JSON in script elements
11
+ 3. **Script Inheritance**: Child custom elements automatically inherit mountobserver scripts from parent elements
12
+ 4. **Duplicate Prevention**: Ensures only one MountObserver is created per registry scope
13
+ 5. **Event Propagation**: Re-dispatches mount events from child elements
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install mount-observer-script-element
19
+ ```
20
+
21
+ ## Basic Usage
22
+
23
+ ```typescript
24
+ import { MOSE } from 'mount-observer-script-element/MOSE.js';
25
+
26
+ class MyElement extends MOSE(HTMLElement) {
27
+ constructor() {
28
+ super();
29
+ }
30
+ }
31
+
32
+ customElements.define('my-element', MyElement);
33
+ ```
34
+
35
+ ```html
36
+ <my-element>
37
+ <script type="mountobserver">
38
+ {
39
+ "whereElementMatches": "button",
40
+ "assignGingerly": {
41
+ "disabled": false,
42
+ "?.dataset?.action": "submit",
43
+ "?.style?.color": "green"
44
+ }
45
+ }
46
+ </script>
47
+ <button>Click me</button>
48
+ </my-element>
49
+ ```
50
+
51
+ ## Features
52
+
53
+ ### 1. Declarative MountObserver Configuration
54
+
55
+ Place `<script type="mountobserver">` elements inside your custom element to configure MountObserver behavior:
56
+
57
+ ```html
58
+ <my-element>
59
+ <script type="mountobserver">
60
+ {
61
+ "whereElementMatches": "input",
62
+ "assignGingerly": {
63
+ "placeholder": "Enter text...",
64
+ "?.style?.backgroundColor": "#f0f0f0"
65
+ }
66
+ }
67
+ </script>
68
+ </my-element>
69
+ ```
70
+
71
+ ### 2. External JSON Configuration
72
+
73
+ Load configuration from external JSON files:
74
+
75
+ ```html
76
+ <script type="mountobserver" src="./config.json"></script>
77
+ ```
78
+
79
+ The JSON file will be loaded using JSON import with `import(src, {with: {type: 'json'}})`.
80
+
81
+ ### 3. Merging Configurations
82
+
83
+ You can combine external and inline configurations. The inline JSON will be merged with the external configuration using [assignGingerly](https://www.npmjs.com/package/assign-gingerly):
84
+
85
+ ```html
86
+ <script type="mountobserver" src="./base-config.json">
87
+ {
88
+ "assignGingerly": {
89
+ "?.style?.color": "red"
90
+ }
91
+ }
92
+ </script>
93
+ ```
94
+
95
+ ### 4. Script Inheritance
96
+
97
+ Child custom elements automatically inherit mountobserver scripts from parent elements of the same type:
98
+
99
+ ```html
100
+ <my-element>
101
+ <script type="mountobserver" src="./shared-config.json"></script>
102
+
103
+ <my-element>
104
+ <!-- This child element automatically inherits the parent's script -->
105
+ <button>I inherit the configuration</button>
106
+ </my-element>
107
+ </my-element>
108
+ ```
109
+
110
+ **How it works:**
111
+ - When a MOSE element is created, it searches for a parent element with the same `localName`
112
+ - It clones all `<script type="mountobserver">` elements from the parent
113
+ - Scripts with duplicate `src` attributes are not cloned (avoiding duplicates)
114
+ - The child element listens for mount events on the parent to inherit dynamically added scripts
115
+
116
+ ### 5. Excluding Inherited Scripts
117
+
118
+ Use the `exclude` property or attribute to prevent specific scripts from being inherited:
119
+
120
+ ```html
121
+ <my-element exclude="script[src*='unwanted']">
122
+ <!-- Scripts matching the exclude selector won't be inherited -->
123
+ </my-element>
124
+ ```
125
+
126
+ ```typescript
127
+ class MyElement extends MOSE(HTMLElement) {
128
+ exclude = "script[data-skip]";
129
+ }
130
+ ```
131
+
132
+ ### 6. Scoped Custom Element Registry Support
133
+
134
+ The mixin works with Chrome's scoped custom element registries:
135
+
136
+ ```typescript
137
+ const customRegistry = new CustomElementRegistry();
138
+
139
+ class ScopedElement extends MOSE(HTMLElement) {
140
+ constructor() {
141
+ super();
142
+ }
143
+ }
144
+
145
+ customRegistry.define('scoped-element', ScopedElement);
146
+
147
+ const element = document.createElement('scoped-element', {
148
+ customElementRegistry: customRegistry
149
+ });
150
+ ```
151
+
152
+ ### 7. Duplicate Element Detection
153
+
154
+ The mixin prevents multiple instances of the same custom element within the same registry scope:
155
+
156
+ ```typescript
157
+ // This will throw an error if another element with the same localName
158
+ // already exists in the same highestCERNode scope
159
+ ```
160
+
161
+ ### 8. Event Propagation
162
+
163
+ When multiple MOSE elements share the same highestCERNode, mount events are propagated:
164
+
165
+ ```typescript
166
+ myElement.addEventListener('mount', (e) => {
167
+ // Receives mount events for elements within this element
168
+ console.log('Element mounted:', e.mountedElement);
169
+ });
170
+ ```
171
+
172
+ ## API
173
+
174
+ ### MOSE Mixin
175
+
176
+ ```typescript
177
+ function MOSE<T extends Constructor<HTMLElement>>(Base: T): T
178
+ ```
179
+
180
+ A TypeScript mixin that adds MountObserver script element functionality to any HTMLElement class.
181
+
182
+ ### getHighestCERNode
183
+
184
+ ```typescript
185
+ function getHighestCERNode(node: Node): Node | null
186
+ ```
187
+
188
+ Utility function that finds the highest node in the DOM tree that shares the same `customElementRegistry` as the provided node.
189
+
190
+ ## How It Works
191
+
192
+ 1. **Initialization**: When a MOSE element is constructed:
193
+ - Checks for duplicate elements in the same registry scope
194
+ - Copies mountobserver scripts from parent elements (inheritance)
195
+ - Sets up a MountObserver to watch for `<script type="mountobserver">` elements
196
+
197
+ 2. **Script Processing**: When a mountobserver script is found:
198
+ - Loads external JSON if `src` attribute is present
199
+ - Parses inline JSON from `innerHTML`
200
+ - Merges configurations using assignGingerly
201
+ - Creates a MountObserver with the merged configuration
202
+
203
+ 3. **Registry Scoping**:
204
+ - Uses `getHighestCERNode()` to find the appropriate scope
205
+ - Ensures only one MountObserver per highestCERNode
206
+ - Stores observer reference using `Symbol.for('cteH9dMG-UWwxVaMwFgvQA')`
207
+
208
+ ## Example: Complete Setup
209
+
210
+ ```html
211
+ <!DOCTYPE html>
212
+ <html>
213
+ <head>
214
+ <script type="importmap">
215
+ {
216
+ "imports": {
217
+ "mount-observer/": "/node_modules/mount-observer/",
218
+ "assign-gingerly/": "/node_modules/assign-gingerly/"
219
+ }
220
+ }
221
+ </script>
222
+ </head>
223
+ <body>
224
+ <script type="module">
225
+ import { MOSE } from './MOSE.js';
226
+
227
+ class MyElement extends MOSE(HTMLElement) {
228
+ constructor() {
229
+ super();
230
+ }
231
+ }
232
+
233
+ customElements.define('my-element', MyElement);
234
+ </script>
235
+
236
+ <my-element>
237
+ <script type="mountobserver">
238
+ {
239
+ "whereElementMatches": "button",
240
+ "assignGingerly": {
241
+ "disabled": false,
242
+ "?.dataset?.action": "submit",
243
+ "?.style": {
244
+ "color": "green",
245
+ "height": "25px"
246
+ }
247
+ }
248
+ }
249
+ </script>
250
+ <button>I am here</button>
251
+ </my-element>
252
+ </body>
253
+ </html>
254
+ ```
255
+
256
+ Result:
257
+ ```html
258
+ <button
259
+ data-action="submit"
260
+ style="color: green; height: 25px;">
261
+ I am here
262
+ </button>
263
+ ```
264
+
265
+ ## Dependencies
266
+
267
+ - [mount-observer](https://www.npmjs.com/package/mount-observer) - Core MountObserver functionality
268
+ - [assign-gingerly](https://www.npmjs.com/package/assign-gingerly) - Property assignment with nested support
269
+
270
+ ## Browser Support
271
+
272
+ Requires Chrome 146+ with Scoped Custom Element Registry support enabled.
273
+
274
+ ## Testing
275
+
276
+ Manual test files are available in the `tests/` directory:
277
+
278
+ - `tests/simple.html` - Basic usage example
279
+ - `tests/inheritance.html` - Script inheritance example
280
+ - `tests/exclude.html` - Exclude functionality example
281
+ - `tests/getHighestCERNode.html` - Registry scoping tests
282
+
283
+ Run the development server:
284
+
285
+ ```bash
286
+ npm run serve
287
+ ```
288
+
289
+ Then open test files in Chrome Canary with scoped custom element registries enabled.
290
+
291
+ ## TypeScript
292
+
293
+ The package is written in TypeScript and includes type definitions. Both `.ts` and compiled `.js` files are included in the repository.
294
+
295
+ To compile:
296
+
297
+ ```bash
298
+ tsc
299
+ ```
300
+
301
+ ## License
302
+
303
+ MIT
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Scope Custom Element Registry</title>
7
+ </head>
8
+ <body>
9
+ <my-element>
10
+ <template shadowrootmode="open">
11
+ <div>
12
+ </div>
13
+ </template>
14
+ </my-element>
15
+ <script>
16
+ const div = document.querySelector('my-element').shadowRoot.querySelector('div');
17
+ const section = document.createElement('section', {
18
+ customElementRegistry: new CustomElementRegistry()
19
+ });
20
+ div.appendChild(section);
21
+ </script>
22
+ </body>
23
+ </html>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Recursively traverses up the DOM tree to find the highest node
3
+ * that shares the same customElementRegistry as the passed in element.
4
+ *
5
+ * @param {Node} node - The starting node to check
6
+ * @returns {Node | null} The highest node with matching customElementRegistry, or null if node is invalid
7
+ */
8
+ export function getHighestCERNode(node) {
9
+ if (!node) {
10
+ return null;
11
+ }
12
+ const startRegistry = node.customElementRegistry;
13
+ let currentNode = node;
14
+ let highestMatch = node;
15
+ while (currentNode) {
16
+ // Check if current node has matching customElementRegistry
17
+ if (currentNode.customElementRegistry === startRegistry) {
18
+ highestMatch = currentNode;
19
+ }
20
+ // Try to get parent element first
21
+ const parent = currentNode.parentElement;
22
+ if (parent) {
23
+ currentNode = parent;
24
+ continue;
25
+ }
26
+ // If no parent element, check for rootNode (shadow root case)
27
+ const root = currentNode.getRootNode();
28
+ if (root && root !== currentNode && root !== document) {
29
+ return root;
30
+ }
31
+ else {
32
+ // Reached the top
33
+ break;
34
+ }
35
+ }
36
+ return highestMatch;
37
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Recursively traverses up the DOM tree to find the highest node
3
+ * that shares the same customElementRegistry as the passed in element.
4
+ *
5
+ * @param {Node} node - The starting node to check
6
+ * @returns {Node | null} The highest node with matching customElementRegistry, or null if node is invalid
7
+ */
8
+ export function getHighestCERNode(node: Node): Node | null {
9
+ if (!node) {
10
+ return null;
11
+ }
12
+
13
+ const startRegistry = (node as any).customElementRegistry;
14
+ let currentNode: Node | null = node;
15
+ let highestMatch: Node = node;
16
+
17
+ while (currentNode) {
18
+ // Check if current node has matching customElementRegistry
19
+ if ((currentNode as any).customElementRegistry === startRegistry) {
20
+ highestMatch = currentNode;
21
+ }
22
+
23
+ // Try to get parent element first
24
+ const parent = (currentNode as any).parentElement as Element | null;
25
+ if (parent) {
26
+ currentNode = parent;
27
+ continue;
28
+ }
29
+
30
+ // If no parent element, check for rootNode (shadow root case)
31
+ const root = currentNode.getRootNode();
32
+ if (root && root !== currentNode && root !== document) {
33
+ return root;
34
+ } else {
35
+ // Reached the top
36
+ break;
37
+ }
38
+ }
39
+
40
+ return highestMatch;
41
+ }
package/imports.html ADDED
@@ -0,0 +1,9 @@
1
+ <script type=importmap>
2
+ {
3
+ "imports": {
4
+ "assign-gingerly/": "/node_modules/assign-gingerly/",
5
+ "mount-observer/": "/node_modules/mount-observer/",
6
+ "mount-observer-script-element/": "/"
7
+ }
8
+ }
9
+ </script>
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { getHighestCERNode } from './getHighestCERNode.js';
2
+ export { MOSE } from './MOSE.js';
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {getHighestCERNode} from './getHighestCERNode.js';
2
+ export {MOSE} from './MOSE.js';