Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mixin/elements@1/example/element/shimmy-tag-a.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
shimmy-tag-a {
font-weight: bold;
}
7 changes: 7 additions & 0 deletions mixin/elements@1/example/element/shimmy-tag-a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class ShimmyTagA extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello world';
}
}

customElements.define('shimmy-tag-a', TagA);
7 changes: 7 additions & 0 deletions mixin/elements@1/example/element/shimmy-tag-b.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class ShimmyTagB extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello world';
}
}

customElements.define('shimmy-tag-b', TagA);
62 changes: 62 additions & 0 deletions mixin/elements@1/example/tests/mixin/ElementsV1Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace Civi\Shimmy\Mixins;

/**
* Assert that the managed-entity mixin is working properly.
*
* This class defines the assertions to run when installing or uninstalling the extension.
* It use called as part of E2E_Shimmy_LifecycleTest.
*
* @see E2E_Shimmy_LifecycleTest
*/
class ElementsV1Test extends \PHPUnit\Framework\Assert {

public function testPreConditions($cv): void {
$this->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', '<shimmy-tag-a> should have JS file');
$this->assertEquals(['shimmy/element/shimmy-tag-a.css'], $items['shimmy-tag-a']['css'] ?? 'MISSING', '<shimmy-tag-a> should have CSS file');
$this->assertEquals(['shimmy/element/shimmy-tag-b.mjs'], $items['shimmy-tag-b']['js'] ?? 'MISSING', '<shimmy-tag-b> 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']), '<shimmy-tag-a> should not be defined.');
$this->assertTrue(!isset($items['shimmy-tag-b']), '<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;
}

}
257 changes: 257 additions & 0 deletions mixin/elements@1/mixin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
<?php

/**
* Allow an extension to autoload "Custom Elements" (aka "Web Components") from *.js, *.mjs, *.css.
*
* Elements are loaded from eponymous files. For example:
*
* <hello-world>
* ==> "$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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const SUBDIR = 'element';
const SUBDIR = 'elements';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it is slightly annoying how inflections vary within Civi. My first reaction was: "Right, consistency is good; fine, let's rename it". And I kind of like how ./elements/ resembles ./templates/. To implement this, it does need a slightly bigger commit:

  • Prefer plural ( elements@1 + ./elements/): 8bf36ec

But I'm a sucker for this kind of thing. If we're going for consistency, then one should look the existing top-level folders and mixins:

  • Folder Names: You can argue that top-levels look more plural or more singular. Really, less than half of folders (~9) indicate a free and unambiguous choice of singular vs plural. (To my eye, those lean plural.) The majority are blurry cases (~16) with abbreviations and proper-names. (To my eye, those lean singular.)
  • Mixin Names: For mixins... the "unambiguous choices" and the "blurry cases" both lean toward "singular" (though plural cases do appear as well).

So... I think that singular is more consistent, in which case -- here's the commit going the other direction:

  • Prefer singular (element@1 + ./element/): 1decafd

Of course, there's a lot that we inherit from upstream ecosystems -- bower_component[s]/, vendor[s]/, js/, css/, bin[s]/, etc -- and they don't point in consistent directions either...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I fear we will have this argument until the end of time...

My view is consistent at least: plurals are more human readable, and thereby well worth one extra character. Anything like this we add should use plurals.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My vote would also be for elements which is consistent with the sibling settings and templates directories.

But whichever we do we should merge this PR and get on with our lives.


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),

@colemanw colemanw Dec 31, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally we also don't escape unicode.

echo json_encode($input, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

]);
}

/**
* 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,
});