@webqit/oohtml
Version:
A suite of new DOM features that brings language support for modern UI development paradigms: a component-based architecture, data binding, and reactivity.
208 lines (196 loc) • 7.91 kB
JavaScript
/**
* @imports
*/
import { getDefs } from './index.js';
import { _, env } from '../util.js';
export default class HTMLModule {
/**
* @instance
*/
static instance( host ) {
return _( host ).get( 'defsmanager::instance' ) || new this( host );
}
/**
* @constructor
*/
constructor( host, parent = null, level = 0 ) {
const { window } = env, { webqit: { realdom, oohtml: { configs } } } = window;
_( host ).get( `defsmanager::instance` )?.dispose();
_( host ).set( `defsmanager::instance`, this );
this.window = window;
this.host = host;
this.config = configs.HTML_IMPORTS;
this.parent = parent;
this.level = level;
this.defs = getDefs( this.host );
this.defId = ( this.host.getAttribute( this.config.attr.def ) || '' ).trim();
this.validateDefId( this.defId );
// ----------
this.realtimeA = realdom.realtime( this.host.content ).children( record => {
this.expose( record.entrants, true );
this.expose( record.exits, false );
}, { live: true, timing: 'sync' } );
// ----------
this.realtimeB = realdom.realtime( this.host ).attr( [ 'src', 'loading' ], ( ...args ) => this.evaluateLoading( ...args ), {
live: true,
atomic: true,
timing: 'sync',
lifecycleSignals: true
} );
// ----------
this.realtimeC = this.evalInheritance();
// ----------
}
/**
* Validates export ID.
*
* @param String defId
*
* @returns Void
*/
validateDefId( defId ) {
if ( [ '@', '/', '*', '#' ].some( token => defId.includes( token ) ) ) {
throw new Error( `The export ID "${ defId }" contains an invalid character.` );
}
}
/**
* Maps module contents as defs.
*
* @param Array entries
* @param Bool isConnected
*
* @returns Void
*/
expose( entries, isConnected ) {
const { window } = env, { webqit: { Observer } } = window;
let dirty, allFragments = this.defs[ '#' ] || [];
entries.forEach( entry => {
if ( entry.nodeType !== 1 ) return;
const isTemplate = entry.matches( this.config.templateSelector );
const defId = ( entry.getAttribute( isTemplate ? this.config.attr.def : this.config.attr.fragmentdef ) || '' ).trim();
if ( isConnected ) {
if ( isTemplate && defId ) {
new HTMLModule( entry, this.host, this.level + 1 );
} else {
allFragments.push( entry );
dirty = true;
if ( typeof requestIdleCallback === 'function' ) {
requestIdleCallback( () => {
this.config.idleCompilers?.forEach( callback => callback.call( this.window, entry ) );
} );
}
}
if ( defId ) {
this.validateDefId( defId );
Observer.set( this.defs, ( !isTemplate && '#' || '' ) + defId, entry );
}
} else {
if ( isTemplate && defId ) { HTMLModule.instance( entry ).dispose(); }
else {
allFragments = allFragments.filter( x => x !== entry );
dirty = true;
}
if ( defId ) Observer.deleteProperty( this.defs, ( !isTemplate && '#' || '' ) + defId );
}
} );
if ( dirty ) Observer.set( this.defs, '#', allFragments );
}
/**
* Evaluates remote content loading.
*
* @param AbortSignal signal
*
* @returns Void
*/
evaluateLoading( [ record1, record2 ], { signal } ) {
const { window: { webqit: { Observer } } } = env;
const src = ( record1.value || '' ).trim();
if ( !src ) return;
let $loadingPromise, loadingPromise = promise => {
if ( !promise ) return $loadingPromise; // Get
$loadingPromise = promise.then( () => interception.remove() ); // Set
};
const loading = ( record2.value || '' ).trim();
const interception = Observer.intercept( this.defs, 'get', async ( descriptor, recieved, next ) => {
if ( loading === 'lazy' ) { loadingPromise( this.load( src, true ) ); }
await loadingPromise();
return next();
}, { signal } );
if ( loading !== 'lazy' ) { loadingPromise( this.load( src ) ); }
}
/**
* Fetches a module's "src".
*
* @param String src
*
* @return Promise
*/
load( src ) {
const { window } = env;
if ( this.host.content.children.length ) return Promise.resolve();
// Ongoing request?
if ( this.fetchInFlight?.src === src ) return this.fetchInFlight.request;
this.fetchInFlight?.controller.abort();
// The promise
const controller = new AbortController();
const fire = ( type, detail ) => this.host.dispatchEvent( new window.CustomEvent( type, { detail } ) );
const request = window.fetch( src, { signal: controller.signal, element: this.host } ).then( response => {
return response.ok ? response.text() : Promise.reject( response.statusText );
} ).then( content => {
this.host.innerHTML = content.trim(); // IMPORTANT: .trim()
fire( 'load' );
return this.host;
} ).catch( e => {
console.error( `Error fetching the bundle at "${ src }": ${ e.message }` );
this.fetchInFlight = null;
fire( 'loaderror' );
return this.host;
} );
this.fetchInFlight = { src, request, controller };
return request;
}
/**
* Evaluates module inheritance.
*
* @returns Void|AbortController
*/
evalInheritance( ) {
if ( !this.parent ) return [];
const { window: { webqit: { Observer } } } = env;
let extendedId = ( this.host.getAttribute( this.config.attr.extends ) || '' ).trim();
let inheritedIds = ( this.host.getAttribute( this.config.attr.inherits ) || '' ).trim().split( ' ' ).map( id => id.trim() ).filter( x => x );
const handleInherited = records => {
records.forEach( record => {
if ( Observer.get( this.defs, record.key ) !== record.oldValue ) return;
if ( [ 'get'/*initial get*/, 'set', 'def' ].includes( record.type ) ) {
Observer[ record.type.replace( 'get', 'set' ) ]( this.defs, record.key, record.value );
} else if ( record.type === 'delete' ) {
Observer.deleteProperty( this.defs, record.key );
}
} );
};
const realtimes = [];
const parentDefsObj = getDefs( this.parent );
if ( extendedId ) {
realtimes.push( Observer.reduce( parentDefsObj, [ extendedId, this.config.api.defs, Infinity ], Observer.get, handleInherited, { live: true } ) );
}
if ( inheritedIds.length ) {
realtimes.push( Observer.get( parentDefsObj, inheritedIds.includes( '*' ) ? Infinity : inheritedIds, handleInherited, { live: true } ) );
}
return realtimes;
}
/**
* Disposes the instance and its processes.
*
* @returns Void
*/
dispose() {
this.realtimeA.disconnect();
this.realtimeB.disconnect();
this.realtimeC.forEach( r => ( r instanceof Promise ? r.then( r => r.abort() ) : r.abort() ) );
Object.entries( this.defs ).forEach( ( [ key, entry ] ) => {
if ( key.startsWith( '#' ) ) return;
HTMLModule.instance( entry ).dispose();
} );
}
}