Fixes #5877 Comlink Telehealth Module (#5878)
[openemr.git] / src / Core / Header.php
blob785b80c40a90c5bb4b04acea3965287f2d618198
1 <?php
3 /**
4 * OpenEMR <https://open-emr.org>.
6 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
7 */
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;
17 /**
18 * Class Header.
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
23 * `setupHeader()`
25 * @package OpenEMR
26 * @subpackage Core
27 * @author Robert Down <robertdown@live.com>
28 * @copyright Copyright (c) 2017-2022 Robert Down
30 class Header
32 private static $scripts;
33 private static $links;
34 private static $isHeader;
36 /**
37 * Setup various <head> elements.
39 * See root_dir/config/config.yaml for available assets
41 * Example usage in a PHP view script:
42 * ```php
43 * // Top of script with require_once statements
44 * use OpenEMR\Core\Header;
46 * // Inside of <head>
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');
58 * ```
60 * Inside of a twig template (Parameters same as before):
61 * ```html
62 * {{ includeAsset() }}
63 * ```
65 * Inside of a smarty template, use | (pipe) delimited string of key names
66 * ```php
67 * {headerTemplate}
68 * {headerTemplate assets='key-of-asset'} (1 optional assets)
69 * {headerTemplate assets='array|of|keys'} (multiple optional assets. ie. via | delimiter)
70 * ```
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
79 * @return string
81 public static function setupHeader($assets = [], $echoOutput = true)
83 // Required tag
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";
87 // BS4 required tag
88 $output .= "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\" />\n";
89 // Favicon
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 -->";
124 if ($echoOutput) {
125 echo $output;
126 } else {
127 return $output;
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)
143 if ($headerMode) {
144 self::$isHeader = true;
145 } else {
146 self::$isHeader = false;
149 try {
150 if ($echoOutput) {
151 echo self::includeAsset($assets);
152 } else {
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
171 * @return string
173 private static function includeAsset($assets = [])
176 if (is_string($assets)) {
177 $assets = [$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");
182 self::$scripts = [];
183 self::$links = [];
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
203 * @return void
205 private static function parseConfigFile($map, $selectedAssets = array())
207 $foundAssets = [];
208 $excludedCount = 0;
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)) {
219 $excludedCount++;
220 continue;
223 $foundAssets[] = $k;
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
236 } else {
237 foreach ($tmp['links'] as $l) {
238 self::$links[] = $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'])) {
249 } else {
250 foreach ($tmpRtl['links'] as $l) {
251 self::$links[] = $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);
278 $scripts = [];
279 $links = [];
281 if ($script) {
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)) {
287 $script = [$script];
290 foreach ($script as $k) {
291 $k = self::parsePlaceholders($k);
292 if ($alreadyBuilt) {
293 $path = $k;
294 } else {
295 $path = self::createFullPath($basePath, $k);
297 $scripts[] = self::createElement($path, 'script', $alreadyBuilt);
301 if ($link) {
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)) {
307 $link = [$link];
310 foreach ($link as $l) {
311 $l = self::parsePlaceholders($l);
312 if ($alreadyBuilt) {
313 $path = $l;
314 } else {
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)
336 $re = '/%(.*)%/';
337 $matches = [];
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);
346 return $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}";
368 } else {
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)
395 try {
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"], '?'));