4 * OpenEMR <https://open-emr.org>.
6 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
9 namespace OpenEMR\Core
;
11 use OpenEMR\Common\Logging\SystemLogger
;
12 use OpenEMR\Events\Core\ScriptFilterEvent
;
13 use OpenEMR\Events\Core\StyleFilterEvent
;
14 use Symfony\Component\Yaml\Yaml
;
15 use Symfony\Component\Yaml\Exception\ParseException
;
20 * Helper class to generate some `<script>` and `<link>` elements based on a
21 * configuration file. This file would be a good place to include other helpers
22 * for creating a `<head>` element, but for now it sufficently handles the
27 * @author Robert Down <robertdown@live.com>
28 * @copyright Copyright (c) 2017-2022 Robert Down
32 private static $scripts;
33 private static $links;
34 private static $isHeader;
37 * Setup various <head> elements.
39 * See root_dir/config/config.yaml for available assets
41 * Example usage in a PHP view script:
43 * // Top of script with require_once statements
44 * use OpenEMR\Core\Header;
47 * // If no special assets are needed:
48 * Header::setupHeader();
50 * // If 1 special asset is needed:
51 * Header::setupHeader('key-of-asset');
53 * // If 2 or more assets are needed:
54 * Header::setupHeader(['array', 'of', 'keys']);
56 * // If wish to not include a normally autoloaded asset
57 * Header::setupHeader('no_main-theme');
60 * Inside of a twig template (Parameters same as before):
62 * {{ includeAsset() }}
65 * Inside of a smarty template, use | (pipe) delimited string of key names
68 * {headerTemplate assets='key-of-asset'} (1 optional assets)
69 * {headerTemplate assets='array|of|keys'} (multiple optional assets. ie. via | delimiter)
72 * The above example will render `<script>` tags and `<link>` tag which
73 * bring in the requested assets from config.yaml
75 * @param array|string $assets Asset(s) to include
76 * @param boolean $echoOutput - if true then echo
77 * if false then return string
78 * @throws ParseException If unable to parse the config file
81 public static function setupHeader($assets = [], $echoOutput = true)
84 $output = "\n<meta charset=\"utf-8\" />\n";
85 // Makes only compatible with MS Edge
86 $output .= "<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n";
88 $output .= "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\" />\n";
90 $output .= "<link rel=\"shortcut icon\" href=\"" . $GLOBALS['images_static_relative'] . "/favicon.ico\" />\n";
91 $output .= self
::setupAssets($assets, true, false);
93 // we need to grab the script
94 $scriptName = $_SERVER['SCRIPT_NAME'];
96 // we fire off events to grab any additional module scripts or css files that desire to adjust the currently executed script
97 $scriptFilterEvent = new ScriptFilterEvent(basename($scriptName));
98 $scriptFilterEvent->setContextArgument(ScriptFilterEvent
::CONTEXT_ARGUMENT_SCRIPT_NAME
, $scriptName);
99 $apptScripts = $GLOBALS['kernel']->getEventDispatcher()->dispatch($scriptFilterEvent, ScriptFilterEvent
::EVENT_NAME
);
101 $styleFilterEvent = new StyleFilterEvent($scriptName);
102 $styleFilterEvent->setContextArgument(StyleFilterEvent
::CONTEXT_ARGUMENT_SCRIPT_NAME
, $scriptName);
103 $apptStyles = $GLOBALS['kernel']->getEventDispatcher()->dispatch($styleFilterEvent, StyleFilterEvent
::EVENT_NAME
);
104 // note these scripts have been filtered to be in the same origin as the current site in pnadmin.php & pnuserapi.php {
106 if (!empty($apptScripts->getScripts())) {
107 $output .= "<!-- Module Scripts Started -->";
108 foreach ($apptScripts->getScripts() as $script) {
109 // we want versions appended
110 $output .= Header
::createElement($script, 'script', false);
112 $output .= "<!-- Module Scripts Ended -->";
115 if (!empty($apptStyles->getStyles())) {
116 $output .= "<!-- Module Styles Started -->";
117 foreach ($apptStyles->getStyles() as $cssSrc) {
118 // we want version appended
119 $output .= Header
::createElement($cssSrc, 'style', false);
121 $output .= "<!-- Module Styles Ended -->";
132 * Can call this function directly rather than using above setupHeader function
133 * if do not want to include the autoloaded assets.
135 * @param array $assets Asset(s) to include
136 * @param boolean $headerMode - if true, then include autoloaded assets
137 * if false, then do not include autoloaded assets
138 * @param boolean $echoOutput - if true then echo
139 * if false then return string
141 public static function setupAssets($assets = [], $headerMode = false, $echoOutput = true)
144 self
::$isHeader = true;
146 self
::$isHeader = false;
151 echo self
::includeAsset($assets);
153 return self
::includeAsset($assets);
155 } catch (\InvalidArgumentException
$e) {
156 error_log(errorLogEscape($e->getMessage()));
161 * Include an asset from a config file.
163 * Static function to read in a YAML file into an array, check if the
164 * $assets keys are in the config file, and from the config file generate
165 * the HTML for a `<script>` or `<link>` tag.
167 * This is a private function, use Header::setupHeader() instead
169 * @param array|string $assets Asset(s) to include
170 * @throws ParseException If unable to parse the config file
173 private static function includeAsset($assets = [])
176 if (is_string($assets)) {
180 // @TODO Hard coded the path to the config file, not good RD 2017-05-27
181 $map = self
::readConfigFile("{$GLOBALS['fileroot']}/config/config.yaml");
185 self
::parseConfigFile($map, $assets);
187 /* adding custom assets in addition */
188 if (is_file("{$GLOBALS['fileroot']}/custom/assets/custom.yaml")) {
189 $customMap = self
::readConfigFile("{$GLOBALS['fileroot']}/custom/assets/custom.yaml");
190 self
::parseConfigFile($customMap, $assets);
193 $linksStr = implode("", self
::$links);
194 $scriptsStr = implode("", self
::$scripts);
195 return "\n{$linksStr}\n{$scriptsStr}\n";
199 * Parse assets from config file
201 * @param array $map Assets to parse into self::$scripts and self::$links
202 * @param array $selectedAssets
205 private static function parseConfigFile($map, $selectedAssets = array())
209 foreach ($map as $k => $opts) {
210 $autoload = (isset($opts['autoload'])) ?
$opts['autoload'] : false;
211 $allowNoLoad = (isset($opts['allowNoLoad'])) ?
$opts['allowNoLoad'] : false;
212 $alreadyBuilt = (isset($opts['alreadyBuilt'])) ?
$opts['alreadyBuilt'] : false;
213 $loadInFile = (isset($opts['loadInFile'])) ?
$opts['loadInFile'] : false;
214 $rtl = (isset($opts['rtl'])) ?
$opts['rtl'] : false;
216 if ((self
::$isHeader === true && $autoload === true) ||
in_array($k, $selectedAssets) ||
($loadInFile && $loadInFile === self
::getCurrentFile())) {
217 if ($allowNoLoad === true) {
218 if (in_array("no_" . $k, $selectedAssets)) {
225 $tmp = self
::buildAsset($opts, $alreadyBuilt);
227 foreach ($tmp['scripts'] as $s) {
228 self
::$scripts[] = $s;
231 if (($k == "bootstrap") && ((!in_array("no_main-theme", $selectedAssets)) ||
(in_array("patientportal-style", $selectedAssets)))) {
232 // Above comparison is to skip bootstrap theme loading when using a main theme or using the patient portal theme
233 // since bootstrap theme is already including in main themes and portal theme via SASS.
234 } else if ($k == "compact-theme" && (in_array("no_main-theme", $selectedAssets) ||
empty($GLOBALS['enable_compact_mode']))) {
235 // Do not display compact theme if it is turned off
237 foreach ($tmp['links'] as $l) {
242 if ($rtl && !empty($_SESSION['language_direction']) && $_SESSION['language_direction'] == 'rtl') {
243 $tmpRtl = self
::buildAsset($rtl, $alreadyBuilt);
244 foreach ($tmpRtl['scripts'] as $s) {
245 self
::$scripts[] = $s;
248 if ($k == "compact-theme" && (in_array("no_main-theme", $selectedAssets) ||
!$GLOBALS['enable_compact_mode'])) {
250 foreach ($tmpRtl['links'] as $l) {
258 if (($thisCnt = count(array_diff($selectedAssets, $foundAssets))) > 0) {
259 if ($thisCnt !== $excludedCount) {
260 (new SystemLogger())->error("Not all selected assets were included in header", ['selectedAssets' => $selectedAssets, 'foundAssets' => $foundAssets]);
265 * Build an html element from config options.
267 * @var array $opts Options
268 * @var boolean $alreadyBuilt - This means the path with cache busting segment has already been built
269 * @return array Array with `scripts` and `links` keys which contain arrays of elements
271 private static function buildAsset($opts = array(), $alreadyBuilt = false)
273 $script = (isset($opts['script'])) ?
$opts['script'] : false;
274 $link = (isset($opts['link'])) ?
$opts['link'] : false;
275 $path = (isset($opts['basePath'])) ?
$opts['basePath'] : '';
276 $basePath = self
::parsePlaceholders($path);
282 if (!is_string($script) && !is_array($script)) {
283 throw new \
InvalidArgumentException("Script must be of type string or array");
286 if (is_string($script)) {
290 foreach ($script as $k) {
291 $k = self
::parsePlaceholders($k);
295 $path = self
::createFullPath($basePath, $k);
297 $scripts[] = self
::createElement($path, 'script', $alreadyBuilt);
302 if (!is_string($link) && !is_array($link)) {
303 throw new \
InvalidArgumentException("Link must be of type string or array");
306 if (is_string($link)) {
310 foreach ($link as $l) {
311 $l = self
::parsePlaceholders($l);
315 $path = self
::createFullPath($basePath, $l);
317 $links[] = self
::createElement($path, 'link', $alreadyBuilt);
321 return ['scripts' => $scripts, 'links' => $links];
325 * Parse a string for $GLOBAL key placeholders %key-name%.
327 * Perform a regex match all in the given subject for anything wrapped in
328 * percent signs `%some-key%` and if that string exists in the $GLOBALS
329 * array, will replace the occurence with the value of that key.
331 * @param string $subject String containing placeholders (%key-name%)
332 * @return string The new string with properly replaced keys
334 public static function parsePlaceholders($subject)
338 preg_match_all($re, $subject, $matches, PREG_SET_ORDER
, 0);
340 foreach ($matches as $match) {
341 if (array_key_exists($match[1], $GLOBALS)) {
342 $subject = str_replace($match[0], $GLOBALS["{$match[1]}"], $subject);
350 * Create the actual HTML element.
352 * @param string $path File path to load
353 * @param string $type Must be `script` or `link`
354 * @return string mixed HTML element
356 private static function createElement($path, $type, $alreadyBuilt)
359 $script = "<script src=\"%path%\"></script>\n";
360 $link = "<link rel=\"stylesheet\" href=\"%path%\" />\n";
362 $template = ($type == 'script') ?
$script : $link;
363 if (!$alreadyBuilt) {
364 $v = $GLOBALS['v_js_includes'];
365 // need to handle header elements that may already have a ? in the parameter.
366 if (strrpos($path, "?") !== false) {
367 $path = $path . "&v={$v}";
369 $path = $path . "?v={$v}";
372 return str_replace("%path%", $path, $template);
376 * Create a full path from given parts.
378 * @param string $base Base path
379 * @param string $path specific path / filename
380 * @return string The full path
382 private static function createFullPath($base, $path)
384 return $base . $path;
388 * Read a config file and turn it into an array.
390 * @param string $file Full path to filename
391 * @return array Array of assets
393 private static function readConfigFile($file)
396 $config = Yaml
::parse(file_get_contents($file));
397 return $config['assets'];
398 } catch (ParseException
$e) {
399 error_log(errorLogEscape($e->getMessage()));
400 // @TODO need to handle this better. RD 2017-05-24
405 * Return relative path to current file
407 * @return string The current file
409 private static function getCurrentFile()
411 //remove web root and query string
412 return str_replace($GLOBALS['webroot'] . '/', '', strtok($_SERVER["REQUEST_URI"], '?'));