import { EvtRt } from '../EvtRt.js';
import { EMC, MountConfig, MountContext } from '../types/mount-observer/types.js';
import '../ElementMountExtension.js';
import 'assign-gingerly/object-extension.js';
import { getParserRegistry } from 'assign-gingerly/parserRegistry.js';
/**
* Handler for EMC (Element Mount Configuration) Script Elements.
* Processes script[type="emc"] elements to declaratively configure element enhancements.
*
* Supports two modes:
* 1. External JSON:
* 2. Inline JSON:
*
* Unlike MountObserverScript, EMC scripts only support single config objects (not arrays).
*/
export class EMCScriptHandler extends EvtRt {
// Static properties define default MountConfig constraints
static matching = 'script[type="emc"]';
static whereInstanceOf = HTMLScriptElement;
async mount(mountedElement: Element, MountConfig: MountConfig, context: MountContext): Promise {
this.abort(); // Clean up event listeners (one-time operation)
const scriptElement = mountedElement as HTMLScriptElement;
// Find containing synthesizer element early (needed for parser waiting)
const synthesizerElement = this.findContainingSynthesizer(scriptElement);
// Check for parser waiting before processing EMC config
const waitForParsers = scriptElement.getAttribute('wait-for-parsers');
if (waitForParsers && synthesizerElement) {
const parserNames = waitForParsers.trim().split(/\s+/).filter(name => name.length > 0);
if (parserNames.length > 0) {
// Read timeout attribute (default: 60000ms = 1 minute)
const timeoutAttr = scriptElement.getAttribute('data-parser-timeout');
const timeout = timeoutAttr ? parseInt(timeoutAttr, 10) : 60000;
try {
// Get scoped registry and wait for parsers
const registry = getParserRegistry(synthesizerElement);
await registry.waitFor(parserNames, timeout);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Parser waiting failed for EMC script:`, errorMessage, scriptElement);
scriptElement.setAttribute('data-emc-error', errorMessage);
return; // Stop processing on timeout/error
}
}
}
let emcConfig = (scriptElement as any).export;
if (!emcConfig) {
// Check if script has src attribute
const srcAttr = scriptElement.getAttribute('src');
if (srcAttr) {
// External JSON mode: import from src
try {
const module = await import(srcAttr, { with: { type: 'json' } } as any);
emcConfig = module.default;
} catch (error) {
throw new Error(`Failed to import JSON from '${srcAttr}': ${error instanceof Error ? error.message : String(error)}`);
}
} else {
// Inline JSON mode: parse textContent
const jsonText = scriptElement.textContent?.trim();
if (!jsonText) {
throw new Error('Script element must have either src attribute or JSON content');
}
try {
emcConfig = JSON.parse(jsonText);
} catch (error) {
throw new Error(`Failed to parse JSON content: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Validate that config is an object (not array)
if (typeof emcConfig !== 'object' || emcConfig === null || Array.isArray(emcConfig)) {
throw new Error('EMC config must be an object (not an array)');
}
// Store the parsed config on the script element's export property
(scriptElement as any).export = emcConfig;
// Dispatch resolved event
const { ResolvedEvent } = await import('../Events.js');
scriptElement.dispatchEvent(new ResolvedEvent(emcConfig));
}
// Validate EMC config has required properties
if (!emcConfig.enhConfig) {
throw new Error('EMC config must have enhConfig property');
}
const enhKey = emcConfig.enhConfig.enhKey;
if (!enhKey) {
throw new Error('EMC config enhConfig must have enhKey property');
}
// Set ID if not specified
if (!scriptElement.id && scriptElement.parentElement) {
scriptElement.id = `${scriptElement.parentElement.localName}.${enhKey}`;
}
// Construct MountConfig from EMC config and mount it
const mountConfig = await this.buildMountConfig(emcConfig, synthesizerElement);
await scriptElement.mount(mountConfig);
}
/**
* Build a MountConfig from an EMC config.
* Combines the matching selector with withAttrs if present.
*/
private async buildMountConfig(emcConfig: EMC, synthesizerElement?: Element): Promise {
const { enhConfig, ...mountConfigBase } = emcConfig;
let matching = mountConfigBase.matching || '';
// If withAttrs is defined, use buildCSSQuery to combine with matching
if (enhConfig.withAttrs) {
const { buildCSSQuery } = await import('assign-gingerly/buildCSSQuery.js');
// Cast to any to avoid type mismatch with spawn property
const attrQuery = buildCSSQuery(enhConfig as any);
// Combine matching with attribute query
if (matching) {
matching = `${matching}${attrQuery}`;
} else {
matching = attrQuery;
}
}
// Create the mount config with a custom handler
const mountConfig: MountConfig = {
...mountConfigBase,
matching,
do: (mountedElement: Element) => {
return this.handleMount(mountedElement, emcConfig, synthesizerElement);
}
};
return mountConfig;
}
/**
* Handle when an element mounts that matches the EMC config.
*/
private async handleMount(mountedElement: Element, emcConfig: EMC, synthesizerElement?: Element): Promise {
const enhKey = emcConfig.enhConfig.enhKey;
// Step 1: Check if element already has this enhancement or is currently being enhanced
const enh = (mountedElement as any).enh;
if (enh && enh[enhKey]) {
// Already enhanced, do nothing
return;
}
// Check for in-flight enhancement to prevent duplicate spawns
// when multiple observers match the same element concurrently
const inflightKey = `__enhInFlight_${String(enhKey)}`;
if ((mountedElement as any)[inflightKey]) {
return;
}
(mountedElement as any)[inflightKey] = true;
// Step 2: Get enhancement registry from the element's custom element registry
const customElementRegistry = (mountedElement as any).customElementRegistry || customElements;
const enhancementRegistry = (customElementRegistry as any).enhancementRegistry;
if (!enhancementRegistry) {
throw new Error('Enhancement registry not found on custom element registry');
}
// Check if enhancement is already registered using findByEnhKey method
let enhancementConfig = enhancementRegistry.findByEnhKey(enhKey);
// Step 3: If not registered, register it
if (!enhancementConfig) {
enhancementConfig = await this.registerEnhancement(emcConfig, enhancementRegistry);
}
// Step 4: Spawn enhancement instance
if (!enh) {
throw new Error('Element does not have enh property. Make sure ElementMountExtension is loaded.');
}
// Wait for defer-[base] attribute removal if applicable
const base = emcConfig.enhConfig.withAttrs?.base;
if (base && mountedElement.hasAttribute(`defer-${base}`)) {
const { awaitAttrRemoval } = await import('../awaitAttrRemoval.js');
await awaitAttrRemoval(mountedElement, `defer-${base}`);
}
// Pass synthesizerElement and full EMC config through SpawnContext
const spawnContext = { synthesizerElement, emc: emcConfig };
await enh.get(enhancementConfig, spawnContext);
}
/**
* Register an enhancement in the enhancement registry.
*/
private async registerEnhancement(emcConfig: EMC, enhancementRegistry: any): Promise {
const { enhConfig } = emcConfig;
const { spawn } = enhConfig;
if (!spawn) {
throw new Error('EMC enhConfig must have spawn property');
}
// Step 3.1: Import the module
const module = await import(spawn);
// Get the enhancement class - it should be the default export or any exported class
let ElementClass = module.default;
// If no default export, try to find a suitable class
if (!ElementClass) {
// Look for any exported constructor function
for (const key of Object.keys(module)) {
if (typeof module[key] === 'function') {
ElementClass = module[key];
break;
}
}
}
if (!ElementClass) {
throw new Error(`No suitable class found in module ${spawn}`);
}
// Step 3.2: Construct enhancement config
const enhancementConfig = {
...enhConfig,
spawn: ElementClass
};
// Step 3.3: Register in enhancement registry
enhancementRegistry.push(enhancementConfig);
return enhancementConfig;
}
/**
* Find the nearest ancestor synthesizer element.
* Traverses up through shadow root boundaries.
* Looks for elements with data-synthesizer attribute, be-hive tag, or __isSynthesizer property.
*/
private findContainingSynthesizer(element: Element): Element | undefined {
let current: Node | null = element;
while (current) {
if (current instanceof Element) {
// Check for synthesizer marker or known synthesizer tag names
if (current.hasAttribute('data-synthesizer') ||
current.localName === 'be-hive' ||
(current as any).__isSynthesizer === true) {
return current;
}
}
// Try parent element
if (current.parentElement) {
current = current.parentElement;
}
// Try shadow root host
else if (current instanceof ShadowRoot) {
current = (current as ShadowRoot).host;
}
// Try parent node (for document fragments)
else if (current.parentNode) {
current = current.parentNode;
}
else {
break;
}
}
return undefined;
}
}
// Register built-in handler
import { MountObserver } from '../MountObserver.js';
export const emc = 'builtIns.emcScript';
MountObserver.define(emc, EMCScriptHandler);