3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
11 use Civi\Core\Event\GenericHookEvent
;
14 * This class facilitates the loading of resources
15 * such as JavaScript files and CSS files.
17 * Any URLs generated for resources may include a 'cache-code'. By resetting the
18 * cache-code, one may force clients to re-download resource files (regardless of
19 * any HTTP caching rules).
21 * TODO: This is currently a thin wrapper over CRM_Core_Region. We
22 * should incorporte services for aggregation, minimization, etc.
25 * @copyright CiviCRM LLC https://civicrm.org/licensing
27 class CRM_Core_Resources
implements CRM_Core_Resources_CollectionAdderInterface
{
28 const DEFAULT_WEIGHT
= 0;
29 const DEFAULT_REGION
= 'page-footer';
31 use CRM_Core_Resources_CollectionAdderTrait
;
34 * We don't have a container or dependency-injection, so use singleton instead
38 private static $_singleton = NULL;
41 * @var CRM_Extension_Mapper
43 private $extMapper = NULL;
46 * @var CRM_Core_Resources_Strings
48 private $strings = NULL;
51 * Any bundles that have been added.
53 * Format is ($bundleName => bool).
57 protected $addedBundles = [];
60 * Added core resources.
62 * Format is ($regionName => bool).
66 protected $addedCoreResources = [];
71 * Format is ($regionName => bool).
75 protected $addedSettings = [];
78 * A value to append to JS/CSS URLs to coerce cache resets.
82 protected $cacheCode = NULL;
85 * The name of a setting which persistently stores the cacheCode.
89 protected $cacheCodeKey = NULL;
92 * Are ajax popup screens enabled.
96 public $ajaxPopupsEnabled;
99 * @var \Civi\Core\Paths
104 * Get or set the single instance of CRM_Core_Resources.
106 * @param CRM_Core_Resources $instance
107 * New copy of the manager.
109 * @return CRM_Core_Resources
111 public static function singleton(CRM_Core_Resources
$instance = NULL) {
112 if ($instance !== NULL) {
113 self
::$_singleton = $instance;
115 if (self
::$_singleton === NULL) {
116 self
::$_singleton = Civi
::service('resources');
118 return self
::$_singleton;
122 * Construct a resource manager.
124 * @param CRM_Extension_Mapper $extMapper
125 * Map extension names to their base path or URLs.
126 * @param CRM_Core_Resources_Strings $strings
127 * JS-localization cache.
128 * @param string|null $cacheCodeKey Random code to append to resource URLs; changing the code forces clients to reload resources
130 public function __construct($extMapper, $strings, $cacheCodeKey = NULL) {
131 $this->extMapper
= $extMapper;
132 $this->strings
= $strings;
133 $this->cacheCodeKey
= $cacheCodeKey;
134 if ($cacheCodeKey !== NULL) {
135 $this->cacheCode
= Civi
::settings()->get($cacheCodeKey);
137 if (!$this->cacheCode
) {
138 $this->resetCacheCode();
140 $this->ajaxPopupsEnabled
= (bool) Civi
::settings()->get('ajaxPopupsEnabled');
141 $this->paths
= Civi
::paths();
145 * Add an item to the collection.
147 * @param array $snippet
149 * The full/computed snippet (with defaults applied).
150 * @see CRM_Core_Resources_CollectionInterface::add()
152 public function add($snippet) {
153 if (!isset($snippet['region'])) {
154 $snippet['region'] = self
::DEFAULT_REGION
;
156 if (!isset($snippet['weight'])) {
157 $snippet['weight'] = self
::DEFAULT_WEIGHT
;
159 return CRM_Core_Region
::instance($snippet['region'])->add($snippet);
163 * Locate the 'settings' snippet.
165 * @param array $options
167 * @see CRM_Core_Resources_CollectionTrait::findCreateSettingSnippet()
169 public function &findCreateSettingSnippet($options = []): array {
170 $options = CRM_Core_Resources_CollectionAdderTrait
::mergeSettingOptions($options, [
173 return $this->getSettingRegion($options['region'])->findCreateSettingSnippet($options);
177 * Assimilate all the resources listed in a bundle.
179 * @param iterable|string|\CRM_Core_Resources_Bundle $bundle
180 * Either bundle object, or the symbolic name of a bundle, or a list of bundles.
181 * Note: For symbolic names, the bundle must be a container service ('bundle.FOO').
184 public function addBundle($bundle) {
185 // There are two ways you might write this method: (1) immediately merge
186 // resources from the bundle, or (2) store a reference to the bundle and
187 // merge resources later. Both have pros/cons. The implementation does #1.
189 // The upshot of #1 is *multi-region* support. For example, a bundle might
190 // add some JS to `html-header` and then add some HTML to `page-header`.
191 // Implementing this requires splitting the bundle (ie copying specific
192 // resources to their respective regions). The timing of `addBundle()` is
193 // favorable to splitting.
195 // The upshot of #2 would be *reduced timing sensitivity for downstream*:
196 // if party A wants to include some bundle, and party B wants to refine
197 // the same bundle, then it wouldn't matter if A or B executed first.
198 // This should make DX generally more forgiving. But we can't split until
199 // everyone has their shot at tweaking the bundle.
201 // In theory, you could have both characteristics if you figure the right
202 // time at which to perform a split. Or maybe you could have both by tracking
203 // more detailed references+events among the bundles/regions. I haven't
204 // seen a simple way to do get both.
206 if (is_iterable($bundle)) {
207 foreach ($bundle as $b) {
208 $this->addBundle($b);
213 if (is_string($bundle)) {
214 $bundle = Civi
::service('bundle.' . $bundle);
217 if (isset($this->addedBundles
[$bundle->name
])) {
220 $this->addedBundles
[$bundle->name
] = TRUE;
222 // Ensure that every asset has a region.
223 $bundle->filter(function($snippet) {
224 if (empty($snippet['region'])) {
225 $snippet['region'] = isset($snippet['settings'])
226 ?
$this->getSettingRegion()->_name
227 : self
::DEFAULT_REGION
;
232 $byRegion = CRM_Utils_Array
::index(['region', 'name'], $bundle->getAll());
233 foreach ($byRegion as $regionName => $snippets) {
234 CRM_Core_Region
::instance($regionName)->merge($snippets);
240 * Helper fn for addSettingsFactory.
242 public function getSettings($region = NULL) {
243 return $this->getSettingRegion($region)->getSettings();
247 * Determine file path of a resource provided by an extension.
250 * extension name; use 'civicrm' for core.
251 * @param string|null $file
252 * file path -- relative to the extension base dir.
254 * @return bool|string
255 * full file path or FALSE if not found
257 public function getPath($ext, $file = NULL) {
258 // TODO consider caching results
259 $base = $this->paths
->hasVariable($ext)
260 ?
rtrim($this->paths
->getVariable($ext, 'path'), '/')
261 : $this->extMapper
->keyToBasePath($ext);
262 if ($file === NULL) {
265 $path = $base . '/' . $file;
266 if (is_file($path)) {
273 * Determine public URL of a resource provided by an extension.
276 * extension name; use 'civicrm' for core.
277 * @param string $file
278 * file path -- relative to the extension base dir.
279 * @param bool $addCacheCode
281 * @return string, URL
283 public function getUrl($ext, $file = NULL, $addCacheCode = FALSE) {
284 if ($file === NULL) {
288 $file = $this->addCacheCode($file);
290 // TODO consider caching results
291 $base = $this->paths
->hasVariable($ext)
292 ?
$this->paths
->getVariable($ext, 'url')
293 : ($this->extMapper
->keyToUrl($ext) . '/');
294 return $base . $file;
298 * Evaluate a glob pattern in the context of a particular extension.
301 * Extension name; use 'civicrm' for core.
302 * @param string|array $patterns
303 * Glob pattern; e.g. "*.html".
304 * @param null|int $flags
307 * List of matching files, relative to the extension base dir.
310 public function glob($ext, $patterns, $flags = NULL) {
311 $path = $this->getPath($ext);
312 $patterns = (array) $patterns;
314 foreach ($patterns as $pattern) {
315 if (preg_match(';^(assetBuilder|ext)://;', $pattern)) {
318 if (CRM_Utils_File
::isAbsolute($pattern)) {
320 $files = array_merge($files, (array) glob($pattern, $flags));
324 $files = array_merge($files, (array) glob("$path/$pattern", $flags));
327 // Deterministic order.
329 $files = array_unique($files);
330 return array_map(function ($file) use ($path) {
331 return CRM_Utils_File
::relativize($file, "$path/");
338 public function getCacheCode() {
339 // Ex: AngularJS json partials are language-specific because they ship with the strings
340 // for the current language.
341 return $this->cacheCode
. CRM_Core_I18n
::getLocale();
346 * @return CRM_Core_Resources
348 public function setCacheCode($value) {
349 $this->cacheCode
= $value;
350 if ($this->cacheCodeKey
) {
351 Civi
::settings()->set($this->cacheCodeKey
, $value);
357 * @return CRM_Core_Resources
359 public function resetCacheCode() {
360 $this->setCacheCode(CRM_Utils_String
::createRandom(5, CRM_Utils_String
::ALPHANUMERIC
));
361 // Also flush cms resource cache if needed
362 CRM_Core_Config
::singleton()->userSystem
->clearResourceCache();
367 * This adds CiviCRM's standard css and js to the specified region of the document.
368 * It will only run once.
370 * @param string $region
371 * @return CRM_Core_Resources
373 public function addCoreResources($region = 'html-header') {
374 if ($region !== 'html-header') {
375 // The signature of this method allowed different regions. However, this
376 // doesn't appear to be used - based on grepping `universe` generally
377 // and `civicrm-{core,backdrop,drupal,packages,wordpress,joomla}` specifically,
378 // it appears that all callers use 'html-header' (either implicitly or explicitly).
379 throw new \
CRM_Core_Exception("Error: addCoreResources only supports html-header");
381 if (!self
::isAjaxMode()) {
382 $this->addBundle('coreResources');
383 $this->addCoreStyles($region);
389 * This will add CiviCRM's standard CSS
391 * @param string $region
392 * @return CRM_Core_Resources
394 public function addCoreStyles($region = 'html-header') {
395 if ($region !== 'html-header') {
396 // The signature of this method allowed different regions. However, this
397 // doesn't appear to be used - based on grepping `universe` generally
398 // and `civicrm-{core,backdrop,drupal,packages,wordpress,joomla}` specifically,
399 // it appears that all callers use 'html-header' (either implicitly or explicitly).
400 throw new \
CRM_Core_Exception("Error: addCoreResources only supports html-header");
402 $this->addBundle('coreStyles');
407 * Flushes cached translated strings.
408 * @return CRM_Core_Resources
410 public function flushStrings() {
411 $this->strings
->flush();
416 * @return CRM_Core_Resources_Strings
418 public function getStrings() {
419 return $this->strings
;
423 * Create dynamic script for localizing js widgets.
425 public static function renderL10nJs(GenericHookEvent
$e) {
426 if ($e->asset
!== 'crm-l10n.js') {
429 $e->mimeType
= 'application/javascript';
430 $params = $e->params
;
432 'contactSearch' => json_encode($params['includeEmailInName'] ?
ts('Search by name/email or id...') : ts('Search by name or id...')),
433 'otherSearch' => json_encode(ts('Enter search term or id...')),
434 'entityRef' => self
::getEntityRefMetadata(),
436 $e->content
= CRM_Core_Smarty
::singleton()->fetchWith('CRM/common/l10n.js.tpl', $params);
441 * is this page request an ajax snippet?
443 public static function isAjaxMode() {
444 if (in_array(CRM_Utils_Array
::value('snippet', $_REQUEST), [
445 CRM_Core_Smarty
::PRINT_SNIPPET
,
446 CRM_Core_Smarty
::PRINT_NOFORM
,
447 CRM_Core_Smarty
::PRINT_JSON
,
452 list($arg0, $arg1) = array_pad(explode('/', CRM_Utils_System
::currentPath()), 2, '');
453 return ($arg0 === 'civicrm' && in_array($arg1, ['ajax', 'angularprofiles', 'asset']));
457 * @param \Civi\Core\Event\GenericHookEvent $e
458 * @see \CRM_Utils_Hook::buildAsset()
460 public static function renderMenubarStylesheet(GenericHookEvent
$e) {
461 if ($e->asset
!== 'crm-menubar.css') {
464 $e->mimeType
= 'text/css';
466 $config = CRM_Core_Config
::singleton();
467 $cms = strtolower($config->userFramework
);
468 $cms = $cms === 'drupal' ?
'drupal7' : $cms;
470 'bower_components/smartmenus/dist/css/sm-core-css.css',
471 'css/crm-menubar.css',
472 "css/menubar-$cms.css",
474 foreach ($items as $item) {
475 $content .= file_get_contents(self
::singleton()->getPath('civicrm', $item));
477 $params = $e->params
;
478 // "color" is deprecated in favor of the more specific "menubarColor"
479 $menubarColor = $params['color'] ??
$params['menubarColor'];
481 '$resourceBase' => rtrim($config->resourceBase
, '/'),
482 '$menubarHeight' => $params['height'] . 'px',
483 '$breakMin' => $params['breakpoint'] . 'px',
484 '$breakMax' => ($params['breakpoint'] - 1) . 'px',
485 '$menubarColor' => $menubarColor,
486 '$menuItemColor' => $params['menuItemColor'] ??
$menubarColor,
487 '$highlightColor' => $params['highlightColor'] ?? CRM_Utils_Color
::getHighlight($menubarColor),
488 '$textColor' => $params['textColor'] ?? CRM_Utils_Color
::getContrast($menubarColor, '#333', '#ddd'),
490 $vars['$highlightTextColor'] = $params['highlightTextColor'] ?? CRM_Utils_Color
::getContrast($vars['$highlightColor'], '#333', '#ddd');
491 $e->content
= str_replace(array_keys($vars), array_values($vars), $content);
495 * Provide a list of available entityRef filters.
499 public static function getEntityRefMetadata() {
504 $config = CRM_Core_Config
::singleton();
506 $disabledComponents = [];
507 $dao = CRM_Core_DAO
::executeQuery("SELECT name, namespace FROM civicrm_component");
508 while ($dao->fetch()) {
509 if (!in_array($dao->name
, $config->enableComponents
)) {
510 $disabledComponents[$dao->name
] = $dao->namespace;
514 foreach (CRM_Core_DAO_AllCoreTables
::daoToClass() as $entity => $daoName) {
515 // Skip DAOs of disabled components
516 foreach ($disabledComponents as $nameSpace) {
517 if (strpos($daoName, $nameSpace) === 0) {
521 $baoName = str_replace('_DAO_', '_BAO_', $daoName);
522 if (class_exists($baoName)) {
523 $filters = $baoName::getEntityRefFilters();
525 $data['filters'][$entity] = $filters;
527 if (is_callable([$baoName, 'getEntityRefCreateLinks'])) {
528 $createLinks = $baoName::getEntityRefCreateLinks();
530 $data['links'][$entity] = $createLinks;
536 CRM_Utils_Hook
::entityRefFilters($data['filters'], $data['links']);
542 * Determine the minified file name.
545 * @param string $file
547 * An updated $fileName. If a minified version exists and is supported by
548 * system policy, the minified version will be returned. Otherwise, the original.
550 public function filterMinify($ext, $file) {
551 if (CRM_Core_Config
::singleton()->debug
&& strpos($file, '.min.') !== FALSE) {
552 $nonMiniFile = str_replace('.min.', '.', $file);
553 if ($this->getPath($ext, $nonMiniFile)) {
554 $file = $nonMiniFile;
564 public function addCacheCode($url) {
565 $hasQuery = strpos($url, '?') !== FALSE;
566 $operator = $hasQuery ?
'&' : '?';
568 return $url . $operator . 'r=' . $this->getCacheCode();
572 * Checks if the given URL is fully-formed
578 public static function isFullyFormedUrl($url) {
579 return (substr($url, 0, 4) === 'http') ||
(substr($url, 0, 1) === '/');
583 * @param string|NULL $region
584 * Optional request for a specific region. If NULL/omitted, use global default.
585 * @return \CRM_Core_Region
587 private function getSettingRegion($region = NULL) {
588 $region = $region ?
: (self
::isAjaxMode() ?
'ajax-snippet' : 'html-header');
589 return CRM_Core_Region
::instance($region);