-
-
Notifications
You must be signed in to change notification settings - Fork 893
component-js@1 - Auto-load web-components from extensions #34325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * Allow an extension to autoload WebComponents (*.js, *.mjs). | ||
| * | ||
| * Components are loaded from eponymous files. For example: | ||
| * | ||
| * <hello-world> --> "$EXTENSION/js/component/hello-world.js" | ||
| * | ||
| * All files are treated as ECMAScript Modules, so they may use `import` statements. | ||
| * | ||
| * If you need to register components with different file-names, use hook_civicrm_componentJsPaths(&$paths). | ||
| * | ||
| * If you're doing funny business (like generating JS files) and need to refresh the index, call: | ||
| * | ||
| * Civi::service('component-js@1')->flush(); | ||
| * | ||
| * @mixinName component-js | ||
| * @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\ComponentJsV1; | ||
|
|
||
| use Civi; | ||
| use CRM_Utils_String; | ||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||
| use Symfony\Component\DependencyInjection\Definition; | ||
|
|
||
| class ComponentJs { | ||
|
|
||
| const ASSET = 'component.js'; | ||
|
|
||
| const SUBDIR = 'js/component'; | ||
|
|
||
| protected $registered = FALSE; | ||
|
|
||
| public static function instance(): ComponentJs { | ||
| if (!isset(Civi::$statics[static::CLASS])) { | ||
| Civi::$statics[static::CLASS] = new ComponentJs(); | ||
| } | ||
| 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']); | ||
| } | ||
|
|
||
| public function container(ContainerBuilder $container): void { | ||
| $container->setDefinition('component-js@1', new Definition(static::CLASS)) | ||
| ->setFactory([static::CLASS, 'instance']) | ||
| ->setPublic(TRUE); | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| * @see \CRM_Utils_Hook::buildAsset() | ||
| */ | ||
| public function buildAsset($asset, $params, &$mimeType, &$content) { | ||
| if ($asset !== static::ASSET) { | ||
| return; | ||
| } | ||
|
|
||
| $mimeType = 'text/javascript'; | ||
| $staticPaths = $this->getStaticPaths(); | ||
| $content = strtr($this->getTemplate(), [ | ||
| 'COMPONENT_JS_STATIC_PATHS' => json_encode($staticPaths, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), | ||
| ]); | ||
| } | ||
|
|
||
| /** | ||
| * @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); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| * @return array | ||
| */ | ||
| public function getStaticPaths(): array { | ||
| $componentPaths = []; | ||
| $event = Civi\Core\Event\GenericHookEvent::create(['componentPaths' => &$componentPaths]); | ||
| Civi::dispatcher()->dispatch('hook_civicrm_componentJsPaths', $event); | ||
| return $componentPaths; | ||
| } | ||
|
|
||
| /** | ||
| * @return string | ||
| */ | ||
| public function getId(): string { | ||
| $id = Civi::cache('long')->get('component-js-id'); | ||
| if ($id === NULL) { | ||
| $id = CRM_Utils_String::createRandom(8, CRM_Utils_String::ALPHANUMERIC); | ||
| Civi::cache('long')->set('component-js-id', $id, 7 * 24 * 60 * 60); | ||
| } | ||
| return $id; | ||
| } | ||
|
|
||
| public function flush(): void { | ||
| Civi::cache('long')->delete('component-js-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 `component-js@1`. | ||
| * | ||
| * @param \CRM_Extension_MixInfo $mixInfo | ||
| * @param \CRM_Extension_BootCache $bootCache | ||
| */ | ||
| return function ($mixInfo, $bootCache) { | ||
|
|
||
| ComponentJs::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 or *.mjs file from $EXTENSION/js/component/. | ||
| */ | ||
| Civi::dispatcher()->addListener('&hook_civicrm_componentJsPaths', function(array &$components) use ($mixInfo) { | ||
| if ($mixInfo->isActive()) { | ||
| $files = array_merge( | ||
| // Only JS files that look like valid tag-names. Allows you to (eg) create `_bundle.js` which is handled separately. | ||
| (array) glob($mixInfo->getPath(ComponentJs::SUBDIR . '/[a-z]*-*.js')), | ||
| (array) glob($mixInfo->getPath(ComponentJs::SUBDIR . '/[a-z]*-*.mjs')), | ||
| ); | ||
| foreach ($files as $file) { | ||
| $tag = preg_replace('/\.(js|mjs)$/', '', basename($file)); | ||
| $components[$tag] = $mixInfo->longName . '/' . ComponentJs::SUBDIR . '/' . basename($file) . '?ts=' . filemtime($file); | ||
| } | ||
| } | ||
| }, 1000); | ||
|
|
||
| }; | ||
|
|
||
| ############################################################################### | ||
| ## Below, we stop executing PHP. The rest of the file contains the template | ||
| ## for "component.js". | ||
|
Comment on lines
+171
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why can't this be in a separate file?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mixin backports/versioning are generally based on one-file/one-mixin. From POV of civicrm-core and PHP interpreter, I suppose one could do more files. But it’s definitely messier on civix side. I’d probably want to have a few more needs before biting off that task |
||
| __HALT_COMPILER(); | ||
| const $ = window.CRM.$; | ||
| const loading = new Set(); | ||
| const registry = window.CRM.componentJs = COMPONENT_JS_STATIC_PATHS; | ||
|
|
||
| function maybeLoad(tagName) { | ||
| if (!registry[tagName] || customElements.get(tagName) || loading.has(tagName)) { | ||
| return; | ||
| } | ||
|
|
||
| loading.add(tagName); | ||
| import(registry[tagName]).catch(err => { | ||
| console.error(`Failed to load ${tagName}`, err); | ||
| loading.delete(tagName); | ||
| }); | ||
| } | ||
|
|
||
| $(()=>{ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is the jQuery here?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might not be needed. My thinking was to defer init until page-start so that the downstream files wouldn’t need to. (Huge portions of Civi JS do this - as a way of saying, “Don’t do anything until all our little addons for Of course, theoretically, the In aggregate, pages might perform better if this part doesn’t try to constrain load-order. But I think it will put the onus on downstream files to think more about ordering. Aside: When using ESM, the static
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reading a bit more about |
||
|
|
||
| // 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, | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this seems very odd to me?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fun fact… we actually use
__HALT_COMPILERstuff for composer.phar, phpunit.phar, drush.phar, ad nauseam. That’s how you get a CLI executable (top part) combined with an archive (bottom part). You just don’t see it :)But this is essentially aesthetic. It could use a
<<<HEREDOCor a$bufferinstead. I thought the payload looked easier to read at the bottom (without extra indentation or escaping stuff).Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would have just expected it in a separate file. I guess it's tightly bundled and unlikely to need much development, so I don't mind much, but yes aesthetically it seems a bit over-clever to me.
(Ah sorry read the other response about work to support multiple files. Fair enough I guess)