<?php
/**
 * @brief		Javascript Handler
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		12 Aug 2013
 */

namespace IPS\Output;

/* To prevent PHP errors (extending class does not exist) revealing path */

use DirectoryIterator;
use Exception;
use Garfix\JsMinify\Minifier;
use InvalidArgumentException;
use IPS\Application;
use IPS\Data\Store;
use IPS\Db;
use IPS\Db\Select;
use IPS\Dispatcher;
use IPS\File;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Lang;
use IPS\Output;
use IPS\Patterns\ActiveRecord;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use RuntimeException;
use UnderflowException;
use XMLReader;
use XMLWriter;
use function array_merge;
use function chmod;
use function count;
use function defined;
use function explode;
use function file;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function implode;
use function in_array;
use function intval;
use function is_array;
use function is_dir;
use function is_file;
use function is_string;
use function json_encode;
use function mb_strtolower;
use function md5;
use function mkdir;
use function preg_match;
use function rtrim;
use function str_ends_with;
use function substr;
use function unlink;
use function urlencode;
use const IPS\CACHEBUST_KEY;
use const IPS\IPS_FILE_PERMISSION;
use const IPS\IPS_FOLDER_PERMISSION;
use const IPS\ROOT_PATH;
use const LOCK_EX;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Javascript: Javascript handler
 */
class Javascript extends ActiveRecord
{
	/**
	 * @brief	[Javascript]	Array of found javascript keys and objects
	 */
	protected static array $foundJsObjects = array();
	
	/**
	 * @brief	[Javascript]	Position index for writing javascript to core_javascript
	 */
	protected static array $positions = array();
	
	/**
	 * @brief	[ActiveRecord] Multiton Store
	 */
	protected static array $multitons;
	
	/**
	 * @brief	[ActiveRecord] Database Table
	 */
	public static ?string $databaseTable = 'core_javascript';
	
	/**
	 * @brief	[ActiveRecord] Database Prefix
	 */
	public static string $databasePrefix = 'javascript_';

	/**
	 * @brief	Javascript map of file object URLs
	 */
	protected static ?array $javascriptObjects = null;
		
	/**
	 * Find JavaScript file
	 *
	 * @param string $app			Application key or Plugin key
	 * @param string $location		Location (front, admin, etc)
	 * @param string $path			Path
	 * @param string $name			Filename
	 * @return    Javascript
	 * @throws	OutOfRangeException
	 */
	public static function find( string $app, string $location, string $path, string $name ): Javascript
	{
		$key = md5( $app . '-' . $location . '-' . $path . '-' . $name );
		
		if ( !in_array( $key, static::$foundJsObjects ) )
		{
			$where  = array( 'javascript_app=?', 'javascript_location=?', 'javascript_path=?', 'javascript_name=?' );
			
			$bindings = array( $app, $location, $path, $name );

			
			try
			{
				$js = Db::i()->select( '*', 'core_javascript', array_merge( array( implode( ' AND ', $where ) ), $bindings ) )->first();
				static::$foundJsObjects[ $key ] = parent::constructFromData( $js );
			}
			catch ( UnderflowException $e )
			{
				throw new OutOfRangeException;
			}
		}
		
		return static::$foundJsObjects[ $key ];
	}

	/**
	 * Set class properties if this object belongs to an application or a plugin
	 *
	 * @return void
	 */
	protected function setAppOrPluginProperties() : void
	{
		if ( ( $this->app AND !is_string( $this->app ) ) OR ( $this->plugin ) )
		{
			$this->app		= 'core';
			$this->location	= 'plugins';
			$this->path		= '/';
			$this->type		= 'plugin';
			$this->plugin	= ( !is_string( $this->app ) ) ? $this->app : $this->plugin;
		}
	}
	
	/**
	 * Create a javascript file. This overwrites any existing JS that matches the same parameters.
	 * If a $this->app is not a string, then it will assume plugin and automatically determine the correct 'app', 'location' and 'path' so these do not need to
	 * be defined.
	 * 
	 * @return    void
	 *@throws	RuntimeException
	 * @throws	InvalidArgumentException
	 */
	public function save(): void
	{
		$this->setAppOrPluginProperties();

		if ( ! isset( $this->path ) OR empty( $this->path ) )
		{
			$this->path = '/';
		}
		
		if ( ! $this->app OR ! $this->location OR ! $this->name )
		{
			throw new InvalidArgumentException;
		}
		
		if ( ! $this->type )
		{
			$this->type = static::_getType( $this->path, $this->name );
		}

		$key = '';

		Db::i()->insert( 'core_javascript', array(
			'javascript_app'		=> $this->app,
			'javascript_location'	=> $this->location,
			'javascript_path'		=> $this->path,
			'javascript_name'		=> $this->name,
			'javascript_type'		=> $this->type,
			'javascript_content'	=> $this->content,
			'javascript_version'	=> $this->version,
			'javascript_position'	=> ( $this->position ) ? $this->position : 2000000,
			'javascript_key'		=> $key
		) );
	}
	
	/**
	 * Delete a javascript file
	 * be defined.
	 * 
	 * @return	void
	 *@throws	InvalidArgumentException
	 */
	public function delete(): void
	{
		$this->setAppOrPluginProperties();
	
		if ( ! isset( $this->path ) OR empty( $this->path ) )
		{
			$this->path = '/';
		}
		
		if ( ! $this->app OR ! $this->location OR ! $this->name )
		{
			throw new InvalidArgumentException;
		}
		
		if ( ! $this->type )
		{
			$this->type = static::_getType( $this->path, $this->name );
		}
		
		$_where    = "javascript_app=? AND javascript_location=? AND javascript_path=? AND javascript_name=?";
		$where = array( $this->app, $this->location, $this->path, $this->name );

		array_unshift( $where, $_where );

		Db::i()->delete( 'core_javascript', $where );
	}
	
	/**
	 * Create an XML document
	 *
	 * @param string $app		Application
	 * @param array $current	Details about current javascript.xml file. Used if $changes is desired to be tracked
	 * @param array|null $changes	If set, will set details of any changes by reference
	 * @return	XMLWriter
	 */
	public static function createXml( string $app, array $current = array(), array &$changes = NULL ): XMLWriter
	{
		static::importDev($app);
		
		if ( $app === 'core' )
		{
			static::importDev('global');
		}
		
		/* Build XML and write to app directory */
		$xml = new XMLWriter;
		$xml->openMemory();
		$xml->setIndent( TRUE );
		$xml->startDocument( '1.0', 'UTF-8' );
		
		/* Root tag */
		$xml->startElement('javascript');
		$xml->startAttribute('app');
		$xml->text( $app );
		$xml->endAttribute();
		
		/* Loop */
		foreach ( Db::i()->select( '*', 'core_javascript', ( $app === 'core' ) ? Db::i()->in( 'javascript_app', array('core', 'global') ) : array( 'javascript_app=?', $app ), 'javascript_path, javascript_location, javascript_name' ) as $js )
		{
			/* Initiate the <template> tag */
			$xml->startElement('file');
			$attributes = array();
			foreach( $js as $k => $v )
			{
				if ( in_array( substr( $k, 11 ), array('app', 'location', 'path', 'name', 'type', 'version', 'position' ) ) )
				{
					$attributes[ $k ] = $v;
					$xml->startAttribute( $k );
					$xml->text( $v );
					$xml->endAttribute();
				}
			}
				
			/* Write value */
			if ( preg_match( '/<|>|&/', $js['javascript_content'] ) )
			{
				$xml->writeCData( str_replace( ']]>', ']]]]><![CDATA[>', $js['javascript_content'] ) );
			}
			else
			{
				$xml->text( $js['javascript_content'] );
			}
				
			/* Close the <template> tag */
			$xml->endElement();
			
			/* Note it */
			$k = "{$attributes['javascript_app']}/{$attributes['javascript_location']}/" . ( trim( $attributes['javascript_path'] ) ? "{$attributes['javascript_path']}/" : '' ) . "{$attributes['javascript_name']}";

			if( $changes !== NULL )
			{
				if ( !isset( $current['files'][ $k ] ) )
				{
					$changes['files']['added'][] = $k;
				}
				elseif ( $current['files'][ $k ] != $js['javascript_content'] )
				{
					$changes['files']['edited'][] = $k;
				}
			}

			unset( $current['files'][ $k ] );
		}

		if( count( static::$_orders ) )
		{
			foreach( static::$_orders as $_app => $orderArray )
			{
				foreach( $orderArray as $order )
				{
					$xml->startElement('order');
					
					$xml->startAttribute( 'app' );
					$xml->text( $_app );
					$xml->endAttribute();

					$xml->startAttribute( 'path' );
					$xml->text( $order['path'] );
					$xml->endAttribute();

					$xml->text( $order['contents'] );

					$xml->endElement();
					
					/* Note it */
					$k = "{$_app}/{$order['path']}";

					if( $changes !== NULL )
					{
						if ( !isset( $current['orders'][ $k ] ) )
						{
							$changes['orders']['added'][] = $k;
						}
						elseif ( $current['orders'][ $k ] != $order['contents'] )
						{
							$changes['orders']['edited'][] = $k;
						}
					}

					unset( $current['orders'][ $k ] );
				}
			}
		}
		
		/* Finish */
		$xml->endDocument();
		
		if( $changes !== NULL )
		{
			$changes['files']['removed'] = array_keys( $current['files'] );
			$changes['orders']['removed'] = array_keys( $current['orders'] );
		}
		
		return $xml;
	}
	
	/**
	 * Import from an XML file on disk
	 * 
	 * @param string $file	File to import from (can be from applications dir, or tmp uploaded file)
	 * @param int|null $offset Offset to begin import from
	 * @param int|null $limit	Number of rows to import
	 * @return	bool|int	False if the file is invalid, otherwise the number of rows inserted
	 */
	public static function importXml( string $file, int $offset=null, int $limit=null ): bool|int
	{
		if ( ! is_file( $file ) )
		{
			return false;
		}

		$i			= 0;
		$inserted	= 0;

		/* Try to prevent timeouts to the extent possible */
		$cutOff			= null;

		if( $maxExecution = @ini_get( 'max_execution_time' ) )
		{
			/* If max_execution_time is set to "no limit" we should add a hard limit to prevent browser timeouts */
			if ( $maxExecution == -1 )
			{
				$maxExecution = 30;
			}
			
			$cutOff	= time() + ( $maxExecution * .5 );
		}
		
		/* Open XML file */
		$xml = \IPS\Xml\XMLReader::safeOpen( $file );
		$xml->read();
		
		$app = $xml->getAttribute('app');
		
		/* Remove existing elements */
		if( $offset === null or $offset === 0 )
		{
			Db::i()->delete( 'core_javascript', array( 'javascript_app=?', $app ) );

			if ( $app === 'core' )
			{
				Db::i()->delete( 'core_javascript', array( 'javascript_app=?', 'global' ) );
			}
		}
		
		while( $xml->read() )
		{
			if( $xml->nodeType != XMLReader::ELEMENT )
			{
				continue;
			}

			if( $cutOff !== null AND time() >= $cutOff )
			{
				break;
			}

			$i++;

			if ( $offset !== null )
			{
				if ( $i - 1 < $offset )
				{
					$xml->next();
					continue;
				}
			}

			$inserted++;

			if( $xml->name == 'file' )
			{
				/* We have a unique key on app, location, path, name so we use replace into to prevent duplicates */
				Db::i()->replace( 'core_javascript', array(
					'javascript_app'		=> $xml->getAttribute('javascript_app'),
					'javascript_key'        => '',
					'javascript_location'	=> $xml->getAttribute('javascript_location'),
					'javascript_path'		=> $xml->getAttribute('javascript_path'),
					'javascript_name'		=> $xml->getAttribute('javascript_name'),
					'javascript_type'		=> $xml->getAttribute('javascript_type'),
					'javascript_content'	=> $xml->readString(),
					'javascript_version'	=> $xml->getAttribute('javascript_version'),
					'javascript_position'	=> $xml->getAttribute('javascript_position')
				) );
			}

			if( $limit !== null AND $i === ( $limit + $offset ) )
			{
				break;
			}
		}

		return $inserted;
	}
	
	/**
	 * Export Javascript to /dev/js
	 *
	 * @param string $app		 Application Directory
	 * @return	void
	 * @throws	RuntimeException
	 */
	public static function exportDev( string $app ) : void
	{
		/* Commenting this out because I cannot find this method anywhere in the codebase */
		/*try
		{
			Developer::writeDirectory( $app, 'js' );
		}
		catch( RuntimeException $e )
		{
			throw new RuntimeException( $e->getMessage() );
		}*/
	
		foreach( Db::i()->select( '*', 'core_javascript', array( 'javascript_app=?', $app ) )->setKeyField('javascript_id') as $jsId => $js )
		{
			$pathToWrite = $js['javascript_location'];
			/*try
			{
				$pathToWrite = Developer::writeDirectory( $app, 'js/', $js['javascript_location'] );
			}
			catch( RuntimeException $e )
			{
				throw new RuntimeException( $e->getMessage() );
			}*/
	
			if ( $js['javascript_path'] != '/' )
			{
				$_path = '';
					
				foreach( explode( '/', trim( $js['javascript_path'], '/' ) ) as $dir )
				{
					$_path .= '/' . trim( $dir, '/' );
					$pathToWrite = 'js/' . $js['javascript_location'] . $_path;
						
					/*try
					{
						$pathToWrite = Developer::writeDirectory( $app, 'js/' . $js['javascript_location'] . $_path );
					}
					catch( RuntimeException $e )
					{
						throw new RuntimeException( $e->getMessage() );
					}*/
				}
			}
	
			if ( ! @file_put_contents( $pathToWrite . '/' . $js['javascript_name'], $js['javascript_content'] ) )
			{
				throw new RuntimeException('core_theme_dev_cannot_write_js,' . $pathToWrite . '/' . $js['javascript_name']);
			}
			else
			{
				@chmod( $pathToWrite . '/' . $js['javascript_name'], 0777 );
			}
		}

		/* Open XML file */
		if( file_exists( ROOT_PATH . '/applications/' . $app . '/data/javascript.xml' ) )
		{
			$xml = \IPS\Xml\XMLReader::safeOpen( ROOT_PATH . '/applications/' . $app . '/data/javascript.xml' );
			$xml->read();

			while( $xml->read() )
			{
				if( $xml->nodeType != XMLReader::ELEMENT )
				{
					continue;
				}
			
				if( $xml->name == 'order' )
				{
					$path = rtrim( $xml->getAttribute('path'), '/' ) . '/';
					$app = $xml->getAttribute('app');
					$content = $xml->readString();

					file_put_contents( ROOT_PATH . $path . 'order.txt', $content );
				}
			}
		}
	}

	/**
	 * @brief	Track order.txt files for writing
	 */
	protected static array $_orders = array();

	/**
	 * Import JS from dev folders and store into core_javascript
	 * 
	 * @param string $app	Application
	 * @return	void
	 */
	public static function importDev( string $app ) : void
	{
		$root = ROOT_PATH . '/applications/' . $app . '/dev/js';
		
		if ( $app == 'global' )
		{
			$root = ROOT_PATH . '/dev/js/';
		}

		static::$_orders[ $app ]	= array();
		
		if ( is_dir( $root ) )
		{
			Db::i()->delete( 'core_javascript', array( 'javascript_app=?', $app ) );
			static::$positions = array();
			
			foreach( new DirectoryIterator( $root ) as $location )
			{
				if ( $location->isDot() OR mb_substr( $location->getFilename(), 0, 1 ) === '.' )
				{
					continue;
				}
		
				if ( $location->isDir() )
				{
					static::_importDevDirectory( $root, $app, $location->getFilename() );
				}
			}
		}
	}
	
	/**
	 * Import a /dev directory recursively.
	 * 
	 * @param string $root		Root directory to recurse
	 * @param string $app		Application key
	 * @param string $location	Location (front, global, etc)
	 * @param string $path		Additional path information
	 * @return	void
	 */
	protected static function _importDevDirectory( string $root, string $app, string $location, string $path='' ) : void
	{
		$dir       = $root . '/' . $location . '/' . $path;
		$parentDir = preg_replace( '#^(.*)/([^/]+?)$#', '\2', $dir );
		
		if ( file_exists( $dir . '/order.txt' ) )
		{
			$contents = file_get_contents( $dir . '/order.txt' );

			/* Enforce \n line endings */
			if( mb_strtolower( mb_substr( PHP_OS, 0, 3 ) ) === 'win' )
			{
				$contents = str_replace( "\r\n", "\n", $contents );
			}

			static::$_orders[ $app ][] = array( 'path' => str_replace( ROOT_PATH, '', $dir ), 'contents' => $contents );

			$order = file( $dir . '/order.txt' );
			
			foreach( $order as $item )
			{
				$item = trim( $item );
				
				if ( isset( static::$positions[ $app . '-' . $location . '-' . $parentDir ] ) AND static::$positions[ $app . '-' . $location . '-' . $parentDir ] < 1000000 )
				{
					static::$positions[ $app . '-' . $location . '-' . $item ] = ++static::$positions[ $app . '-' . $location . '-' . $parentDir ];
				}
				else
				{
					static::$positions[ $app . '-' . $location . '-' . $item ] = static::_getNextPosition( $app, $location );
				}
			}
		}
		
		foreach ( new DirectoryIterator( $dir ) as $file )
		{
			if ( $file->isDot() || mb_substr( $file->getFilename(), 0, 1 ) === '.' || $file == 'index.html' )
			{
				continue;
			}
				
			if ( $file->isDir() )
			{
				static::_importDevDirectory( $root, $app, $location, $path . '/' . $file->getFileName() );
			}
			else if ( mb_substr( $file->getFileName(), -3 ) === '.js' )
			{
				$js = file_get_contents( $dir . '/' . $file->getFilename() );
				
				if ( isset( static::$positions[ $app . '-' . $location . '-' . $file->getFilename() ] ) )
				{
					$position = static::$positions[ $app . '-' . $location . '-' . $file->getFilename() ];
				}
				else
				{
					/* Attempt to order by files */
					if ( ! isset( static::$positions[  $app . '-' . $location . '-' . $parentDir ] ) )
					{
						static::$positions[  $app . '-' . $location . '-' . $parentDir ] = static::_getNextPosition( $app, $location ) + 1000000;
					}
					
					$position = static::$positions[  $app . '-' . $location . '-' . $parentDir ];
				}
				
				/* Check to see if 'ips.{dir}.js' exists and if so, put that first */
				if ( $file->getFilename() == 'ips.' . $parentDir . '.js' )
				{
					$position = $position - 1;
				}
				
				$path = trim( $path, '/' );

				Db::i()->delete( 'core_javascript', array( 'javascript_app=? AND javascript_location=? AND javascript_path=? AND javascript_name=?', $app, $location, $path, $file->getFileName() ) );
				
				Db::i()->insert( 'core_javascript', array(
						'javascript_app'		=> $app,
						'javascript_location'	=> $location,
						'javascript_path'		=> $path,
						'javascript_name'		=> $file->getFileName(),
						'javascript_type'		=> static::_getType( $dir . '/', $file->getFileName() ),
						'javascript_content'	=> ( mb_strtolower( mb_substr( PHP_OS, 0, 3 ) ) === 'win' ) ? str_replace( "\r\n", "\n", $js ) : $js,
						'javascript_position'   => $position,
						'javascript_version'	=> Application::load( ( $app == 'global' ? 'core' : $app ) )->long_version,
						'javascript_key'		=> md5( $app . ';' . $location . ';' . $path . ';' . $file->getFileName() )
				) );
			}
		}
	}
	
	/**
	 * Delete a compiled JS file
	 * 
	 * @param string $app		Application
	 * @param string|null $location	Location (front, global, etc)
	 * @param string|null $file		File to remove
	 * @return	void
	 */
	public static function deleteCompiled( string $app, string $location=null, string $file=null ): void
	{
		$map   = ( isset( Store::i()->javascript_map ) ) ? Store::i()->javascript_map : array();

		if ( $location === NULL and $file === NULL )
		{
			if ( isset( $map[ $app ] ) )
			{
				foreach( $map[ $app ] as $hash => $path )
				{
					try
					{
						File::get( 'core_Theme', $path )->delete();
					}
					catch( Exception $e ) { }
				}
				
				$map[ $app ] = array();
			}
		}
		else
		{
			if ( isset( $map[ $app ] ) )
			{
				foreach( $map[ $app ] as $hash => $path )
				{
					if ( $file === NULL )
					{
						$lookFor = 'javascript_' . $app . '/' . $location . '_';
					}
					else
					{
						$lookFor = 'javascript_' . $app . '/' . $location . '_' . $file;
					}
					
					if ( mb_substr( $path, 0, mb_strlen( $lookFor ) ) === $lookFor )
					{
						try
						{
							File::get( 'core_Theme', $path )->delete();
						}
						catch( Exception $e ) { }
						
						unset( $map[ $app ][ $hash ] );
					}
				}
			}
		}
		
		Store::i()->javascript_map = $map;
	}

	/**
	 * Get the URL of the JS language file
	 *
	 * @param Lang $language
	 * @return string
	 */
	public static function getLanguageUrl( Lang $language ): string
	{
		if ( \IPS\IN_DEV )
		{
			return Url::baseUrl() . "/applications/core/interface/js/jslang.php?langId=" . $language->id;
		}
		else
		{
			if ( $language->file )
			{
				try
				{
					$fileUrl = Url::createFromString( File::get( 'core_Theme', $language->file )->url );
					return $fileUrl->setQueryString( 'v', Javascript::javascriptCacheBustKey() );
				}
				catch( Exception $e )
				{
					/* Best try and write it */
					$fileUrl = Url::createFromString( File::get( 'core_Theme', (string ) static::writeLanguage( $language ) )->url );
					return $fileUrl->setQueryString( 'v', Javascript::javascriptCacheBustKey() );
				}
			}
			else
			{
				/* Write it */
				$fileUrl = Url::createFromString( File::get( 'core_Theme', (string ) static::writeLanguage( $language ) )->url );
				return $fileUrl->setQueryString( 'v', Javascript::javascriptCacheBustKey() );
			}
		}
	}

	/**
	 * Write the language JS files to the file system
	 *
	 * @param Lang $language
	 * @return File
	 */
	public static function writeLanguage( Lang $language ): File
	{
		$lang = [];

		foreach ( Db::i()->select( '*', 'core_sys_lang_words', array( 'lang_id=? AND word_js=?', $language->id, TRUE ) ) as $row )
		{
			$lang[ $row['word_key'] ] = $row['word_custom'] ?: $row['word_default'];
		}

		if ( \IPS\IN_DEV )
		{
			foreach ( Application::enabledApplications() as $app )
			{
				$lang = array_merge( $lang, Lang::readLangFiles( $app->directory, true ) );
			}
		}

		$file = static::_writeForFileSystem( 'ips.setString( ' . json_encode( $lang ) . ')', 'js_lang_' . $language->id . '.js', 'global', 'root' );

		$language->file = (string) $file;
		Db::i()->update( 'core_sys_lang', [ 'lang_file' => (string) $file ], [ 'lang_id=?', $language->id ] );

		/* Clear the data store, so when it is rebuilt, the rebuild store has the lang_file data in it */
		if ( isset( Store::i()->languages ) )
		{
			unset( Store::i()->languages );
		}

		return $file;
	}

	/**
	 * Remove language files
	 *
	 * @param Lang $language
	 * @return void
	 */
	public static function clearLanguage( Lang $language ): void
	{
		if ( $language->file )
		{
			try
			{
				File::get( 'core_Theme', $language->file )->delete();
			}
			catch( \IPS\Db\Exception ) { }
		}

		Db::i()->update( 'core_sys_lang', [ 'lang_file' => '' ], [ 'lang_id=?', $language->id ] );

		/* Clear the data store, so the language file can be rebuilt on the next process */
		if ( isset( Store::i()->languages ) )
		{
			unset( Store::i()->languages );
		}
	}
	
	/**
	 * Compiles JS into fewer minified files suitable for non IN_DEV use.
	 * Imports the fewer files into a database for writing out.
	 * 
	 * @param string $app		Application
	 * @param string|null $location	Location (front, global, etc)
	 * @param string|null $file		File to build
	 * @return	boolean|null
	 */
	public static function compile( string $app, string $location=null, string $file=null ): ?bool
	{
		$flagKey = 'js_compiling_' . md5( $app . ',' . $location . ',' . $file );
		if ( Theme::checkLock( $flagKey ) )
		{
			return NULL;
		}

		Theme::lock( $flagKey );

		$map   = ( isset( Store::i()->javascript_map ) and is_array( Store::i()->javascript_map ) ) ? Store::i()->javascript_map      : array();
		$files = static::getFileMapStore();
		
		if ( $location === null and $file === null and ! in_array( $app, IPS::$ipsApps ) )
		{
			$map[ $app ]   = array();
			$files[ $app ] = array();
			
			Store::i()->javascript_file_map = $files;
		}
		
		if ( $app == 'global' )
		{
			if ( $location === null and $file === null )
			{
				File::getClass('core_Theme')->deleteContainer( 'javascript_' . $app );
			}

			foreach( Output::$globalJavascript as $fileName )
			{			
				if ( $file === null OR $file == $fileName )
				{
					$rows = iterator_to_array( Db::i()->select(
						'*',
						'core_javascript', array( 'javascript_app=? AND javascript_location=?', $app, mb_substr( $fileName, 0, -3 ) ),
						'javascript_app, javascript_location, javascript_position'
					)->setKeyField('javascript_id') );

					/* Web Components are not loaded along with the rest of dev/js/framework */
					$locationRows = [];
					$componentRows = [];
					foreach ( $rows as $id => $row )
					{
						if ( $fileName === 'framework.js' and $row['javascript_type'] === 'component' )
						{
							$componentRows[$id] = $row;
						}
						else
						{
							$locationRows[$id] = $row;
						}
					}

					/* Write it */
					$obj = static::_writeJavascriptFromResultset( $locationRows, $fileName, $app, 'root' );

					$map[ $app ][ md5( $app .'-' . 'root' . '-' . $fileName ) ] = $obj ? (string) $obj : null;

					/* Web Components should have their own file */
					foreach ( $componentRows as $id => $row )
					{
						$componentName = explode( '.', $row['javascript_name'] )[0];
						if ( !preg_match( "/^[a-zA-Z][a-zA-Z0-9]*$/", $componentName ) )
						{
							continue;
						}
						$row['javascript_content'] = static::wrapWebComponentContents( $row['javascript_content'], $componentName );
						$componentObj = static::_writeJavascriptFromResultset( [ $id => $row ], "component_" . $row['javascript_name'], $app, 'root' );
						$map[ $app ][ md5( $app . '-root-' . "component_" . $row['javascript_name'] ) ] = $componentObj ? (string) $componentObj : null;
					}
				}
			}
		}
		else
		{
			if ( $location === null and $file === null )
			{
				File::getClass('core_Theme')->deleteContainer( 'javascript_' . $app );
			}
			
			foreach( array( 'front', 'admin', 'global' ) as $loc )
			{
				if ( ( $file === null OR $file === 'app.js' ) AND ( $location === null OR $location === $loc ) )
				{
					/* app.js: All models and ui for the app */
					$obj = static::_writeJavascriptFromResultset( Db::i()->select( '*', 'core_javascript', array( 'javascript_app=? AND javascript_location=? AND javascript_type IN (\'mixins\', \'model\',\'ui\')', $app, $loc ), 'javascript_app, javascript_location, javascript_position' )->setKeyField('javascript_id'), 'app.js', $app, $loc );

					$map[ $app ][ md5( $app .'-' . $loc . '-' . 'app.js' ) ] = $obj ? (string) $obj : null;
				}
				
				/* {location}_{controller}.js: Controllers and templates bundles */
				$controllers = array();
				$templates   = array();
				
				foreach( Db::i()->select( '*', 'core_javascript', array( 'javascript_app=? AND javascript_location=? AND javascript_type IN (\'controller\', \'template\')', $app, $loc ), 'javascript_app, javascript_location, javascript_position' )->setKeyField('javascript_id') as $id => $row )
				{
					if ( $row['javascript_type'] == 'controller' )
					{
						[ $dir, $controller ] = explode( '/', $row['javascript_path'] );
						
						$controllers[ $controller ][] = $row;
					}
					else
					{
						/* ips . templates . {controller} . js */
						$bits = explode( '.', $row['javascript_name'] );
						$templates[ $bits[2] ][] = $row; 
					}
				}
				
				/* Check to see if we have a template that does not have a controller */
				$templateOnlyKeys = array_diff( array_keys( $templates ), array_keys( $controllers ) );
				
				foreach( array_merge( array_keys( $controllers ), array_keys( $templates ) ) as $key )
				{
					if ( ( $file === null ) OR ( $file === $loc . '_' . $key . '.js' ) )
					{
						$files = array();
						
						if ( isset( $templates[ $key ] ) )
						{
							foreach( $templates[ $key ] as $id => $row )
							{
								$files[ $row['javascript_id'] ] = $row;
							}
						}
						
						if ( isset( $controllers[ $key ] ) )
						{
							foreach( $controllers[ $key ] as $controller )
							{
								$files[ $controller['javascript_id'] ] = $controller;
							}
						}
						
						/* Template only? */
						if ( count( $templateOnlyKeys ) AND in_array( $key, $templateOnlyKeys ) )
						{
							foreach( $templates[ $key ] as $tmpl )
							{
								$files[ $tmpl['javascript_id'] ] = $tmpl;
							}
						}
						
						$obj = static::_writeJavascriptFromResultset( $files, $loc . '_' . $key . '.js', $app, $loc );

						$map[$app][md5( $app . '-' . $loc . '-' . $loc . '_' . $key . '.js' )] = $obj ? (string)$obj : null;
					}
				}
			}
		}

		/* Update the map making sure to remove any IPS apps */
		$ipsApps = array_merge( [ 'global'] , IPS::$ipsApps );
		foreach( $map as $application => $locations )
		{
			if ( in_array( $application, $ipsApps ) )
			{
				unset( $map[ $application ] );
			}
		}

		Store::i()->javascript_map = $map;
		Settings::i()->changeValues( array( 'javascript_updated' => time() ) );

		Theme::unlock( $flagKey );
		
		return TRUE;
	}

	/**
	 * Combines the DB rows into a single string for writing.
	 *
	 * @param Select|array $files Result set
	 * @param string $fileName Filename to use
	 * @param string $app Application
	 * @param string $location Location (front, global, etc)
	 * @return    object|string|null        \IPS\File object
	 */
	protected static function _writeJavascriptFromResultset( Select|array $files, string $fileName, string $app, string $location ): object|string|null
	{
		$content = array();
		$jsMap   = ( isset( Store::i()->javascript_map ) and is_array( Store::i()->javascript_map ) ) ? Store::i()->javascript_map : array();
		
		/* Try and remove any existing files */
		try
		{
			$md5 = md5( $app . '-' . $location . '-' . $fileName );
			
			if ( isset( $jsMap[ $app ] ) and in_array( $md5, array_keys( $jsMap[ $app ] ) ) )
			{
				File::get( 'core_Theme', $jsMap[ $app ][ $md5 ] )->delete();
			}
		}
		catch ( InvalidArgumentException $e ) { }

		if ( ! count( $files ) )
		{
			return null;
		}
		
		foreach( $files as $row )
		{
			$content[] = static::_minify( $row['javascript_content'] ) . ";"; 
		}

		if ( ( in_array( $app, IPS::$ipsApps ) OR $app === 'global' ) )
		{
			$fileObject = static::_writeForStatic( implode( "\n", $content ), $fileName, $app, $location );

		}
		else
		{
			$fileObject = static::_writeForFileSystem( implode( "\n", $content ), $fileName, $app, $location );
		}

		$map = static::getFileMapStore();

		/* Update the map if it's a third party app */
		foreach ( $files as $row )
		{
			if ( in_array( $row['javascript_location'], [ 'front', 'admin', 'global' ] ) )
			{
				$path = ( ( ! empty( $row['javascript_path'] ) AND $row['javascript_path'] !== '/' ) ? '/' . $row['javascript_path'] . '/' : '/' );
				$map[$row['javascript_app']][$row['javascript_location']][$path][$row['javascript_name']] = (string)$fileObject;
			}
		}

		/* Update the map making sure to remove any IPS apps */
		$ipsApps = array_merge( [ 'global'] , IPS::$ipsApps );
		foreach( $map as $application => $locations )
		{
			if ( in_array( $application, $ipsApps ) )
			{
				unset( $map[ $application ] );
			}
		}

		Store::i()->javascript_file_map = $map;
		
		return $fileObject;
	}
	
	/**
	 * Combines the DB rows into a single string for writing.
	 *
	 * @param string $content	Javascript string to write
	 * @param string $fileName	Filename to use
	 * @param string $app		Application
	 * @param string $location	Location (front, global, etc)
	 * @return	File		\IPS\File object
	 */
	protected static function _writeForFileSystem( string $content, string $fileName, string $app, string $location ): File
	{
		return File::create( 'core_Theme', $location . '_' . $fileName, $content, 'javascript_' . $app, TRUE, NULL, FALSE );
	}

	/**
	 * Combines the DB rows into a single string for writing.
	 *
	 * @param string $content	Javascript string to write
	 * @param string $fileName	Filename to use
	 * @param string $app		Application
	 * @param string $location	Location (front, global, etc)
	 * @return	File|string		\IPS\File object
	 */
	protected static function _writeForStatic( string $content, string $fileName, string $app, string $location ): File|string
	{
		$path = '/static/js/' . $app . '/'; // Applications are in the root on Cloud2
		$jsFileName = $path . $location . '_' . $fileName;

		if( !is_dir( ROOT_PATH. $path ) )
		{
			mkdir( ROOT_PATH . $path, IPS_FOLDER_PERMISSION, TRUE );
		}

		$result = (bool) @file_put_contents( ROOT_PATH . $jsFileName, $content, LOCK_EX );

		/* Sometimes LOCK_EX is unavailable and throws file_put_contents(): Exclusive locks are not supported for this stream.
			While we would prefer an exclusive lock, it would be better to write the file if possible. */
		if( !$result )
		{
			@unlink( ROOT_PATH . $jsFileName );
			$result = (bool) @file_put_contents( ROOT_PATH . $jsFileName, $content );
		}

		@chmod( ROOT_PATH . $jsFileName, IPS_FILE_PERMISSION );
		return $jsFileName;
	}

	/**
	 * Get javascript map as a script
	 *
	 * @return array
	 */
	public static function getJavascriptFileMap(): array
	{
		$fileMap =static::getFileMapStore();
		$map     = array();

		/* Fix up the map a little */
		foreach( $fileMap as $app => $location )
		{
			if ( $app === 'global' )
			{
				continue;
			}

			foreach( $location as $locName => $locData )
			{
				foreach( $locData as $name => $items )
				{
					if ( mb_stristr( $name, '/controllers/' ) )
					{
						$url = array_pop( $items );

						$map[ $app ][ $locName . '_' . trim( str_replace( '/controllers/', '', $name ) , '/' ) ] = (string) File::get( 'core_Theme', $url )->url;
					}
				}
			}
		}

		return $map;
	}
	
	/**
	 * Minifies javascript
	 * 
	 * @param string $js	Javascript code
	 * @return string
	 */
	protected static function _minify( string $js ): string
	{
		require_once( ROOT_PATH . '/system/3rd_party/JsMinify/Minifier.php' );
		require_once( ROOT_PATH . '/system/3rd_party/JsMinify/MinifierError.php' );
		require_once( ROOT_PATH . '/system/3rd_party/JsMinify/MinifierExpressions.php' );

		return Minifier::minify( $js, array( 'flaggedComments' => false ) );
	}
	
	/**
	 * Get JS
	 *
	 * @param string $file		Filename
	 * @param string|null $app		Application
	 * @param string|null $location	Location (e.g. 'admin', 'front', 'components')
	 * @return	array		URL to JS files
	 */
	public static function inDevJs( string $file, string $app=NULL, string $location=NULL ): array
	{
		/* 1: Is it a named grouped collection? */
		if ( $app === NULL AND $location === NULL )
		{
			if ( $file === 'map.js' )
			{
				return array();
			}
			
			if ( in_array( $file, Output::$globalJavascript ) )
			{
				$app      = 'global';
				$location = '/';
			}
		}

		// Loading a web component? we then need to load from the file directly
		if ( $location === "components" )
		{
			if ( str_ends_with( $file, '.js' ) )
			{
				$file = mb_substr( $file, 0, -3 );
			}
			return array( Url::baseUrl() . "/applications/core/interface/js/webcomponents.php?component=" . urlencode( $file ) );
		}
	
		$app      = $app      ?: ( Dispatcher::i()->application ? Dispatcher::i()->application->directory : NULL );
		$location = $location ?: Dispatcher::i()->controllerLocation;
		
		/* 2: App JS? */
		if ( $file == 'app.js' )
		{
			return static::_appJs( $app, $location );
		}
		
		/* 3: Is this a controller/template combo? */
		if ( mb_strstr( $file, '_') AND mb_substr( $file, -3 ) === '.js' )
		{
			[ $location, $key ] = explode( '_',  mb_substr( $file, 0, -3 ) );
			
			if ( ( $location == 'front' OR $location == 'admin' OR $location == 'global' ) AND ! empty( $key ) )
			{ 
				return static::_sectionJs( $key, $location, $app );
			}
		}
		
		/* 4: Is it in the interface directory? */
		if ( $location === 'interface' )
		{
			$path = ROOT_PATH . "/applications/{$app}/interface/{$file}";
		}
		else if ( $app === 'global' )
		{
			$return = array();
			
			if ( in_array( $file, Output::$globalJavascript ) )
			{
				return static::_directoryJs( ROOT_PATH . "/dev/js/" . mb_substr( $file, 0, -3 ) );
			}

			$path = ROOT_PATH . "/dev/js";
		}
		else
		{
			$path = ROOT_PATH . "/applications/{$app}/dev/js/{$location}/{$file}";
		}
		
		if ( is_dir( $path ) )
		{
			return static::_directoryJs( $path );
		}
		else
		{
			return array( str_replace( ROOT_PATH, Url::baseUrl(), $path ) );
		}
	}

	/**
	 * Get the map for IN_DEV use
	 *
	 * @return array|string
	 */
	public static function inDevMapJs(): array|string
	{
		$files = array();

		foreach( Application::enabledApplications() as $app => $data )
		{
			$root = ROOT_PATH . "/applications/{$app}/dev/js/";

			foreach( array( 'front', 'admin', 'global' ) as $location )
			{
				if ( is_dir( $root . "{$location}/controllers" ) )
				{
					foreach ( new DirectoryIterator( $root . "{$location}/controllers" ) as $controllerDir )
					{
						if ( $controllerDir->isDot() || mb_substr( $controllerDir->getFilename(), 0, 1 ) === '.' )
						{
							continue;
						}
							
						if ( $controllerDir->isDir() )
						{
							$controllerPath	= ROOT_PATH . "/applications/{$app}/dev/js/{$location}/controllers/{$controllerDir}";

							foreach ( new DirectoryIterator( $root . "{$location}/controllers/{$controllerDir}" ) as $file )
							{
								if ( $file->isDot() || mb_substr( $file->getFilename(), 0, 1 ) === '.' || $file == 'index.html' )
								{
									continue;
								}

								$files[ $app ][ $location . '_' . $controllerDir ] = str_replace( ROOT_PATH, rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ), $controllerPath ) . '/' . $file->getFileName();
							}
						}
					}
				}
			}
		}

		$json = json_encode( $files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		return 'var ipsJavascriptMap = ' . $json . ';';
	}

	/**
	 * Returns the cache bust key for javascript
	 *
	 * @return string
	 */
	public static function javascriptCacheBustKey(): string
	{
		return CACHEBUST_KEY . Settings::i()->javascript_updated;
	}

	/**
	 * Returns the component parts from a URL
	 * 
	 * @param string $url	Full URL of javascript file
	 * @return array	Array( 'app' => .., 'location' => .., 'path' => .., 'name' => .. );
	 */
	protected static function _urlToComponents( string $url ): array
	{
		$url  = ltrim( str_replace( Url::baseUrl( Url::PROTOCOL_RELATIVE ) . 'applications', '', $url ), '/' );
		$bits = explode( "/", $url );
		
		$app = array_shift( $bits );
		
		/* Remove dev/js */
		array_shift( $bits );
		array_shift( $bits );
		
		$location = array_shift( $bits );
		$name     = array_pop( $bits );
		$path	  = preg_replace( '#/{2,}#', '/', '/' . trim( implode( '/', $bits ), '/' ) . '/' );
		
		return array( 'app' => $app, 'location' => $location, 'name' => $name, 'path' => $path );
	}
	
	/**
	 * Gets app specific Javascript
	 * 
	 * @param string $app	    Application
	 * @param string $location	Location (front, global, etc)
	 * @return  array
	 */
	protected static function _appJs( string $app, string $location ): array
	{
		$models = array();

		/* Only include if the app is enabled */
		if( !in_array( $app, array_keys( Application::enabledApplications() ) ) )
		{
			return $models;
		}
		
		if ( is_dir( ROOT_PATH . "/applications/" . $app ) )
		{
			foreach( array( ROOT_PATH . "/applications/{$app}/dev/js/{$location}/mixins", ROOT_PATH . "/applications/{$app}/dev/js/{$location}/models", ROOT_PATH . "/applications/{$app}/dev/js/{$location}/ui" ) as $durr )
			{
				/* Models */
				if ( is_dir( $durr ) )
				{
					$models = static::_directoryJs( $durr );
				}
			}
		}
		
		return $models;
	}
	
	/**
	 * Returns section specific JS (controller, models and any template files required)
	 * 
	 * @param string $key			Controller Key (messages, reports, etc)
	 * @param string $location		Location (front, admin)
	 * @param string $app			Application
	 * @return	array
	 */
	protected static function _sectionJs( string $key, string $location, string $app ): array
	{
		$return        = array();
		$controllerDir = ROOT_PATH . "/applications/{$app}/dev/js/{$location}/controllers/{$key}";
		$templatesDir  = ROOT_PATH . "/applications/{$app}/dev/js/{$location}/templates";
		$controllers   = array();
		$templates     = array();
		
		/* Get controllers */
		if ( is_dir( $controllerDir ) )
		{
			$controllers = static::_directoryJs( $controllerDir );
		}
		
		/* Templates */
		if ( is_dir( $templatesDir . '/' . $key ) )
		{
			$templates = static::_directoryJs( $templatesDir . '/' . $key );
		}
		else if ( file_exists( $templatesDir . '/ips.templates.' . $key . '.js' ) )
		{
			$templates = array( str_replace( ROOT_PATH, rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ), $templatesDir . '/ips.templates.' . $key . '.js' ) );
		}
		
		return array_merge( $templates, $controllers );
	}
	
	/**
	 * Get Javascript files recursively.
	 * 
	 * @param string $path		Path to open
	 * @param array $return	Items retreived so far
	 * @return array
	 */
	protected static function _directoryJs( string $path, array $return=array() ): array
	{
		$path     = rtrim( $path, '/' );
		$contents = array();
		
		foreach ( new DirectoryIterator( $path ) as $file )
		{
			if ( $file->isDot() || mb_substr( $file->getFilename(), 0, 1 ) === '.' )
			{
				continue;
			}

			// Skip the /framework/common/components directory
			if ( $file->isDir() && !( str_ends_with( $path, 'dev/js/framework/common' ) and rtrim( $file->getFilename(), '/' ) == 'components' ) )
			{
				$return = static::_directoryJs( $path . '/' . $file->getFileName(), $return );
			}
			else if ( mb_substr( $file->getFileName(), -3 ) === '.js' )
			{
				$contents[] = str_replace( ROOT_PATH, rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ), $path ) . '/' . $file->getFileName();
			}
		}
		
		if ( count( $contents ) )
		{
			/* Check to see if 'ips.{dir}.js' exists and if so, put that first */
			$parentDir = preg_replace( '#^(.*)/([^/]+?)$#', '\2', $path );
				
			$reordered = array();
				
			foreach( $contents as $url )
			{
				if ( mb_strstr( $url, '/' . $parentDir . '/' ) AND mb_strstr( $url, '/' . $parentDir . '/ips.' . $parentDir . '.js' ) )
				{
					$reordered[] = $url;
					break;
				}
			}
			
			$return = array_merge( $reordered, array_diff( $contents, $reordered ), $return );
		}
		
		$reordered = array();
		
		if ( is_dir( $path ) AND file_exists( $path . '/order.txt' ) )
		{
			$order = file( $path . '/order.txt' );
			
			foreach( $order as $item )
			{
				foreach( $return as $url )
				{
					$item = trim( $item );
					
					if ( mb_substr( $item, -3 ) === '.js' )
					{
						if ( mb_substr( $url, -(mb_strlen( $item ) ) ) == $item )
						{
							$reordered[] = $url;
						}
					}
					else
					{
						if ( mb_substr( str_replace( Url::baseUrl( Url::PROTOCOL_RELATIVE ) . ltrim( str_replace( ROOT_PATH, '', $path ), '/' ), '', $url ), 1, (mb_strlen( $item ) ) ) == $item )
						{
							$reordered[] = $url;
						}
					}
				}
			}
		}
		
		if ( count( $reordered ) )
		{
			/* Add in items not specified in the order */
			$diff = array_diff( $return, $reordered );
				
			if ( count( $diff ) )
			{
				foreach( $diff as $url )
				{
					$reordered[] = $url;
				}
			}
			
			return $reordered;
		}
		
		return $return;
	}
	
	/**
	 * Returns the type of javascript file
	 * @param string $path	Path
	 * @param string $name	File Name
	 * @return string
	 */
	protected static function _getType( string $path, string $name ): string
	{
		$type = 'framework';
		
		if ( mb_strstr( $path, '/controllers/' ) )
		{
			$type = 'controller';
		}
		else if ( mb_strstr( $path, '/models/' ) )
		{
			$type = 'model';
		}
		else if ( mb_strstr( $path, '/mixins/' ) )
		{
			$type = 'mixins';
		}
		else if ( mb_strstr( $path, '/components/' ) )
		{
			$type = 'component';
		}
		else if ( mb_strstr( $path, '/ui/' ) )
		{
			$type = 'ui';
		}
		else if ( mb_strstr( $name, 'ips.templates.' ) )
		{
			$type = 'template';
		}
	
		return $type;
	}
	
	/**
	 * Returns an incremented position integer for this app and location
	 *
	 * @param string $app		Application key
	 * @param string $location	Location (front, global, etc)
	 * @return	int
	 */
	protected static function _getNextPosition( string $app, string $location ): int
	{
		if ( ! isset( static::$positions[ $app . '-' . $location ] ) )
		{
			static::$positions[ $app . '-' . $location ] = 0;
		}
		
		static::$positions[ $app . '-' . $location ] += 50;
		
		return static::$positions[ $app . '-' . $location ];
	}

	/**
	 * Rebuild JS map from database
	 *
	 * @return array
	 */
	public static function getFileMapStore(): array
	{
		try
		{
			$map = Store::i()->javascript_file_map;

			if ( ! is_array( $map ) )
			{
				$map = array();
			}
		}
		catch( Exception )
		{
			$map = [];

			foreach ( Db::i()->select( '*', 'core_javascript', ['javascript_type=? and javascript_app IN(?)', 'controller', implode( ',', array_merge( [ 'global'] , IPS::$ipsApps ) ) ] ) as $row )
			{
				$path = ( ( !empty( $row['javascript_path'] ) and $row['javascript_path'] !== '/' ) ? '/' . $row['javascript_path'] . '/' : '/' );
				$bits = explode( '/', $row['javascript_path'] );
				$name = array_pop( $bits );
				$map[$row['javascript_app']][$row['javascript_location']][$path][$row['javascript_name']] = 'javascript_' . $row['javascript_app'] . '/' . $row['javascript_location'] . '_' . $row['javascript_location'] . '_' . $name . '.js';
			}

			Store::i()->javascript_file_map = $map;
		}

		return $map;
	}

	/**
	 * Wrap contents with the web components
	 *
	 * @param string $contents
	 * @param string $component The component's file name
	 * @return string
	 */
	protected static function wrapWebComponentContents( string $contents, string $component ) : string
	{
		/* Start with some light validation to make sure the component is a valid string */
		if ( !preg_match( '/^[a-zA-Z][a-zA-Z0-9]*$/', $component ) )
		{
			throw new InvalidArgumentException( "The name {$component} is invalid. Expected an alphanumeric string (camel case)" );
		}

		$encodedComponent = json_encode( $component );
		$errorStatement = <<<JS
Debug.log(`The source file for the component \${{$encodedComponent}} does not contain a class that extends HTMLElement. If the component extends a class other than HTMLElement, ips.ui.registerWebComponent() will need to be invoked directly.`);
JS;
		if ( preg_match( "/class\s+([a-zA-Z][a-zA-Z0-9]*)\s+extends\s+HTMLElement/", $contents, $matches ) )
		{
			$classStatement = <<<JS
ips.ui.registerWebComponent( {$encodedComponent}, {$matches[1]} );
Debug.log(`Submitted the web component constructor, {$matches[1]}, for \${{$encodedComponent}}`);
JS;

		}
		else
		{
			$classStatement = $errorStatement;
		}

		return <<<JS
;(function() {
"use strict";
{$contents}
    
{$classStatement}
})();
JS;
	}

	/**
	 * Generate the JS that defines a Web Component
	 *
	 * @param string $component
	 *
	 * @return string
	 */
	public static function generateWebComponent( string $component ) : string
	{
		/* Start with some light validation to make sure the component is a valid string */
		if ( !preg_match( '/^[a-zA-Z][a-zA-Z0-9]*$/', $component ) )
		{
			throw new InvalidArgumentException( "The name {$component} is invalid. Expected an alphanumeric string (camel case)" );
		}

		if ( \IPS\IN_DEV )
		{
			$path = Application::getRootPath() . '/dev/js/framework/common/components/' . $component . '.js';
			if ( file_exists( $path ) and $contents = file_get_contents( $path ) )
			{
				return static::wrapWebComponentContents( $contents, $component );
			}
		}
		else
		{
			$path = Application::getRootPath() . "static/js/global/root_component_" . $component . 'js';
			if ( file_exists( $path ) and $contents = file_get_contents( $path ) )
			{
				return $contents;
			}
		}
		throw new OutOfRangeException( "Cannot find file $path" );
	}
} 