From 52e5a0b4d069614ad3f647fd6b83dfef40a808d8 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 19 Dec 2025 00:46:35 -0800 Subject: [PATCH] elements@1 - Auto-load custom-elements (web-components) from extensions --- .../example/element/shimmy-tag-a.css | 3 + .../example/element/shimmy-tag-a.js | 7 + .../example/element/shimmy-tag-b.mjs | 7 + .../example/tests/mixin/ElementsV1Test.php | 62 +++++ mixin/elements@1/mixin.php | 257 ++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 mixin/elements@1/example/element/shimmy-tag-a.css create mode 100644 mixin/elements@1/example/element/shimmy-tag-a.js create mode 100644 mixin/elements@1/example/element/shimmy-tag-b.mjs create mode 100644 mixin/elements@1/example/tests/mixin/ElementsV1Test.php create mode 100644 mixin/elements@1/mixin.php diff --git a/mixin/elements@1/example/element/shimmy-tag-a.css b/mixin/elements@1/example/element/shimmy-tag-a.css new file mode 100644 index 000000000000..f8f13cd0cc23 --- /dev/null +++ b/mixin/elements@1/example/element/shimmy-tag-a.css @@ -0,0 +1,3 @@ +shimmy-tag-a { + font-weight: bold; +} diff --git a/mixin/elements@1/example/element/shimmy-tag-a.js b/mixin/elements@1/example/element/shimmy-tag-a.js new file mode 100644 index 000000000000..bdb455dba17a --- /dev/null +++ b/mixin/elements@1/example/element/shimmy-tag-a.js @@ -0,0 +1,7 @@ +class ShimmyTagA extends HTMLElement { + connectedCallback() { + this.textContent = 'Hello world'; + } +} + +customElements.define('shimmy-tag-a', TagA); diff --git a/mixin/elements@1/example/element/shimmy-tag-b.mjs b/mixin/elements@1/example/element/shimmy-tag-b.mjs new file mode 100644 index 000000000000..4fd422063462 --- /dev/null +++ b/mixin/elements@1/example/element/shimmy-tag-b.mjs @@ -0,0 +1,7 @@ +class ShimmyTagB extends HTMLElement { + connectedCallback() { + this.textContent = 'Hello world'; + } +} + +customElements.define('shimmy-tag-b', TagA); diff --git a/mixin/elements@1/example/tests/mixin/ElementsV1Test.php b/mixin/elements@1/example/tests/mixin/ElementsV1Test.php new file mode 100644 index 000000000000..7975dc4a688a --- /dev/null +++ b/mixin/elements@1/example/tests/mixin/ElementsV1Test.php @@ -0,0 +1,62 @@ +assertFileExists(static::getPath('/element/shimmy-tag-a.js'), 'The shimmy extension must have example file shimmy-tag-a.js.'); + $this->assertFileExists(static::getPath('/element/shimmy-tag-a.css'), 'The shimmy extension must have example file shimmy-tag-a.css.'); + $this->assertFileExists(static::getPath('/element/shimmy-tag-b.mjs'), 'The shimmy extension must have example file shimmy-tag-b.mjs.'); + } + + private function getAllElements($cv): array { + return $cv->phpEval(' + $svc = "elements@1"; + $c = \Civi::container(); + return $c->has($svc) ? $c->get($svc)->getAll() : []; + '); + } + + public function testInstalled($cv): void { + $items = $this->trimFileNames($this->getAllElements($cv)); + $this->assertEquals(['shimmy/element/shimmy-tag-a.js'], $items['shimmy-tag-a']['js'] ?? 'MISSING', ' should have JS file'); + $this->assertEquals(['shimmy/element/shimmy-tag-a.css'], $items['shimmy-tag-a']['css'] ?? 'MISSING', ' should have CSS file'); + $this->assertEquals(['shimmy/element/shimmy-tag-b.mjs'], $items['shimmy-tag-b']['js'] ?? 'MISSING', ' should have JS file'); + $this->assertTrue(empty($items['shimmy-tag-b']['css']), 'shimmy-tag-b should not have any CSS'); + } + + protected function trimFileNames($items): array { + foreach ($items as $elementName => &$element) { + foreach ($element as $field => &$value) { + if ($field === 'js' || $field === 'css') { + $value = array_map(fn($file) => preg_replace(';(\?ts=.*)$;', '', $file), $value); + } + } + } + return $items; + } + + public function testDisabled($cv): void { + $items = $this->getAllElements($cv); + $this->assertTrue(!isset($items['shimmy-tag-a']), ' should not be defined.'); + $this->assertTrue(!isset($items['shimmy-tag-b']), ' should not be defined.'); + } + + public function testUninstalled($cv): void { + $this->testDisabled($cv); + } + + protected static function getPath($suffix = ''): string { + return dirname(__DIR__, 2) . $suffix; + } + +} diff --git a/mixin/elements@1/mixin.php b/mixin/elements@1/mixin.php new file mode 100644 index 000000000000..81db9661c3ee --- /dev/null +++ b/mixin/elements@1/mixin.php @@ -0,0 +1,257 @@ + + * ==> "$EXTENSION/element/hello-world.js" + * ==> "$EXTENSION/element/hello-world.css" + * + * All [M]JS files are treated as ECMAScript Modules, so they may use `import` statements. + * (In some deployments, `import`s may work with relative-paths. This is not currently guaranteed. + * For stronger compatibility, imports SHOULD rely on the import-map prefix for `$EXTENSION/`.) + * + * If you need to register elements with different file-layouts, use hook_civicrm_elements(array &$elements). + * + * If you're doing funny business (like generating JS files) and need to refresh the index, call: + * + * Civi::service('elements@1')->flush(); + * + * @mixinName elements + * @mixinVersion 1.0.0 + * @since 6.11 + * + * Note: Requires hook_esmImportMap (v5.63+). + * Note: Requires core mixin-loader (v5.45+; built-in version-handling). + */ +namespace Civi\Mixin\ElementsV1; + +use Civi; +use CRM_Utils_String; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +class Elements { + + const ASSET = 'elements.js'; + + const SUBDIR = 'element'; + + protected $registered = FALSE; + + public static function instance(): Elements { + if (!isset(Civi::$statics[static::CLASS])) { + Civi::$statics[static::CLASS] = new Elements(); + } + return Civi::$statics[static::CLASS]; + } + + public function register(): void { + if ($this->registered) { + return; + } + $this->registered = TRUE; + + Civi::dispatcher()->addListener('&hook_civicrm_container', [$this, 'container']); + Civi::dispatcher()->addListener('&hook_civicrm_buildAsset', [$this, 'buildAsset']); + Civi::dispatcher()->addListener('&hook_civicrm_alterBundle', [$this, 'alterBundle']); + } + + /** + * Register as a service in the container. + * + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + * @return void + */ + public function container(ContainerBuilder $container): void { + $container->setDefinition('elements@1', new Definition(static::CLASS)) + ->setFactory([static::CLASS, 'instance']) + ->setPublic(TRUE); + } + + /** + * Render the `elements.js`, which provides an autoloader and registry. + * + * @internal + * @see \CRM_Utils_Hook::buildAsset() + */ + public function buildAsset($asset, $params, &$mimeType, &$content) { + if ($asset !== static::ASSET) { + return; + } + + $mimeType = 'text/javascript'; + $registry = $this->getAll(); + $content = strtr($this->getTemplate(), [ + 'ELEMENTS_REGISTRY' => json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ]); + } + + /** + * Add the `elements.js` autoloader to all regular page-views. + * + * @internal + * @see \CRM_Utils_Hook::alterBundle() + */ + public function alterBundle(\CRM_Core_Resources_Bundle $bundle) { + if ($bundle->name === 'coreResources') { + $url = Civi::service('asset_builder')->getUrl(static::ASSET, ['id' => $this->getId()]); + $bundle->addModuleUrl($url); + // There might be some optimization opportunity to fetch the autoloader + // in parallel with the main HTML doc. addModuleUrl() doesn't currently support + // that (tho I suppose addMarkup() would). In any case, if you pursue that, then + // also look closely at the init steps in `elements.js`. + } + } + + /** + * Get a list of all known elements. + * + * @api + * @return array + */ + public function getAll(): array { + $elements = []; + $event = Civi\Core\Event\GenericHookEvent::create(['elements' => &$elements]); + Civi::dispatcher()->dispatch('hook_civicrm_elements', $event); + return $elements; + } + + /** + * @return string + */ + public function getId(): string { + $id = Civi::cache('long')->get('elements-id'); + if ($id === NULL) { + $id = CRM_Utils_String::createRandom(8, CRM_Utils_String::ALPHANUMERIC); + Civi::cache('long')->set('elements-id', $id, 7 * 24 * 60 * 60); + } + return $id; + } + + /** + * @api + */ + public function flush(): void { + Civi::cache('long')->delete('elements-id'); + } + + /** + * @return string + */ + private function getTemplate(): string { + $fp = fopen(__FILE__, 'r'); + fseek($fp, __COMPILER_HALT_OFFSET__); + $template = stream_get_contents($fp); + fclose($fp); + return $template; + } + +} + +/** + * As a mixin, we receive a notification for each extension that enables `elements@1`. + * + * @param \CRM_Extension_MixInfo $mixInfo + * @param \CRM_Extension_BootCache $bootCache + */ +return function ($mixInfo, $bootCache) { + + Elements::instance()->register(); + + /** + * Register this extension with the ESM import-map ('my-extension/' => '/var/www/sites/default/civicrm/ext/my-extension'). + * + * @see \CRM_Utils_Hook::esmImportMap() + */ + Civi::dispatcher()->addListener('&hook_civicrm_esmImportMap', function(\Civi\Esm\ImportMap $importMap) use ($mixInfo) { + if ($mixInfo->isActive()) { + $importMap->addPrefix($mixInfo->longName . '/', $mixInfo->longName); + } + }, 500); + + /** + * Statically register each *.js, *.mjs, *.css file from $EXTENSION/element/. + */ + Civi::dispatcher()->addListener('&hook_civicrm_elements', function(array &$elements) use ($mixInfo) { + if ($mixInfo->isActive()) { + $fileTypes = [ + '[a-z]*-*.js' => 'js', + '[a-z]*-*.mjs' => 'js', + '[a-z]*-*.css' => 'css', + ]; + foreach ($fileTypes as $filePattern => $fileType) { + $files = (array) glob($mixInfo->getPath(Elements::SUBDIR . '/' . $filePattern)); + foreach ($files as $file) { + $elementName = preg_replace('/\.(js|mjs|css)$/', '', basename($file)); + if (!isset($elements[$elementName][$fileType])) { + $elements[$elementName][$fileType] = []; + } + $elements[$elementName][$fileType][] = $mixInfo->longName . '/' . Elements::SUBDIR . '/' . basename($file) . '?ts=' . filemtime($file); + } + } + } + }, 1000); + +}; + +############################################################################### +## Below, we stop executing PHP. The rest of the file contains the template +## for "elements.js". +__HALT_COMPILER(); +const loading = new Set(); +const registry = window.CRM.elements = ELEMENTS_REGISTRY; + +async function maybeLoad(tagName) { + if (!registry[tagName] || customElements.get(tagName) || loading.has(tagName)) { + return; + } + + loading.add(tagName); + const { js = [], css = [] } = registry[tagName]; + + try { + css.forEach(logicalUri => { + const href = import.meta.resolve(logicalUri); + if (!document.querySelector(`link[href="${href}"]`)) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + document.head.appendChild(link); + } + }); + + await Promise.all(js.map(src => import(src))); + + } catch (err) { + console.error(`Failed to load assets for ${tagName}`, err); + // Remove from loading set so a retry can be attempted later if needed + loading.delete(tagName); + } +} + +// Scan initial DOM +document.querySelectorAll('*').forEach(el => { + maybeLoad(el.tagName.toLowerCase()); +}); + +// Watch for new nodes +const observer = new MutationObserver(mutations => { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType === 1) { + maybeLoad(node.tagName.toLowerCase()); + node.querySelectorAll?.('*').forEach(el => + maybeLoad(el.tagName.toLowerCase()) + ); + } + } + } +}); + +observer.observe(document.documentElement, { + childList: true, + subtree: true, +});