Completed
Push — master ( 66a8db...1f22ee )
by Nazar
04:31
created

Includes::add_includes_on_page()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 38
Code Lines 22

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 5
eloc 22
nc 3
nop 0
dl 0
loc 38
rs 8.439
1
<?php
2
/**
3
 * @package   CleverStyle CMS
4
 * @author    Nazar Mokrynskyi <[email protected]>
5
 * @copyright Copyright (c) 2014-2016, Nazar Mokrynskyi
6
 * @license   MIT License, see license.txt
7
 */
8
namespace cs\Page;
9
use
10
	cs\App,
11
	cs\Core,
12
	cs\Config,
13
	cs\Event,
14
	cs\Language,
15
	cs\Request,
16
	cs\Response,
17
	cs\User,
18
	h,
19
	cs\Page\Includes\Cache,
20
	cs\Page\Includes\Collecting,
21
	cs\Page\Includes\RequireJS;
22
23
/**
24
 * Includes management for `cs\Page` class
25
 *
26
 * @property string $Title
27
 * @property string $Description
28
 * @property string $canonical_url
29
 * @property string $Head
30
 * @property string $post_Body
31
 * @property string $theme
32
 */
33
trait Includes {
34
	use
35
		Cache,
36
		Collecting,
37
		RequireJS;
38
	protected $extension_to_as = [
39
		'jpeg'  => 'image',
40
		'jpe'   => 'image',
41
		'jpg'   => 'image',
42
		'gif'   => 'image',
43
		'png'   => 'image',
44
		'svg'   => 'image',
45
		'svgz'  => 'image',
46
		'ttf'   => 'font',
47
		'ttc'   => 'font',
48
		'otf'   => 'font',
49
		'woff'  => 'font',
50
		'woff2' => 'font',
51
		'eot'   => 'font',
52
		'css'   => 'style',
53
		'js'    => 'script',
54
		'html'  => 'document'
55
	];
56
	/**
57
	 * @var array
58
	 */
59
	protected $core_html;
60
	/**
61
	 * @var array
62
	 */
63
	protected $core_js;
64
	/**
65
	 * @var array
66
	 */
67
	protected $core_css;
68
	/**
69
	 * @var string
70
	 */
71
	protected $core_config;
72
	/**
73
	 * @var array
74
	 */
75
	protected $html;
76
	/**
77
	 * @var array
78
	 */
79
	protected $js;
80
	/**
81
	 * @var array
82
	 */
83
	protected $css;
84
	/**
85
	 * @var string
86
	 */
87
	protected $config;
88
	/**
89
	 * Base name is used as prefix when creating CSS/JS/HTML cache files in order to avoid collisions when having several themes and languages
90
	 * @var string
91
	 */
92
	protected $pcache_basename_path;
93
	protected function init_includes () {
94
		$this->core_html            = ['path' => [], 'plain' => ''];
95
		$this->core_js              = ['path' => [], 'plain' => ''];
96
		$this->core_css             = ['path' => [], 'plain' => ''];
97
		$this->core_config          = '';
98
		$this->html                 = ['path' => [], 'plain' => ''];
99
		$this->js                   = ['path' => [], 'plain' => ''];
100
		$this->css                  = ['path' => [], 'plain' => ''];
101
		$this->config               = '';
102
		$this->pcache_basename_path = '';
103
	}
104
	/**
105
	 * Including of Web Components
106
	 *
107
	 * @param string|string[] $add  Path to including file, or code
108
	 * @param string          $mode Can be <b>file</b> or <b>code</b>
109
	 *
110
	 * @return \cs\Page
111
	 */
112
	function html ($add, $mode = 'file') {
113
		return $this->html_internal($add, $mode);
114
	}
115
	/**
116
	 * @param string|string[] $add
117
	 * @param string          $mode
118
	 * @param bool            $core
119
	 *
120
	 * @return \cs\Page
121
	 */
122
	protected function html_internal ($add, $mode = 'file', $core = false) {
123
		return $this->include_common('html', $add, $mode, $core);
124
	}
125
	/**
126
	 * Including of JavaScript
127
	 *
128
	 * @param string|string[] $add  Path to including file, or code
129
	 * @param string          $mode Can be <b>file</b> or <b>code</b>
130
	 *
131
	 * @return \cs\Page
132
	 */
133
	function js ($add, $mode = 'file') {
134
		return $this->js_internal($add, $mode);
135
	}
136
	/**
137
	 * @param string|string[] $add
138
	 * @param string          $mode
139
	 * @param bool            $core
140
	 *
141
	 * @return \cs\Page
142
	 */
143
	protected function js_internal ($add, $mode = 'file', $core = false) {
144
		return $this->include_common('js', $add, $mode, $core);
145
	}
146
	/**
147
	 * Including of CSS
148
	 *
149
	 * @param string|string[] $add  Path to including file, or code
150
	 * @param string          $mode Can be <b>file</b> or <b>code</b>
151
	 *
152
	 * @return \cs\Page
153
	 */
154
	function css ($add, $mode = 'file') {
155
		return $this->css_internal($add, $mode);
156
	}
157
	/**
158
	 * @param string|string[] $add
159
	 * @param string          $mode
160
	 * @param bool            $core
161
	 *
162
	 * @return \cs\Page
163
	 */
164
	protected function css_internal ($add, $mode = 'file', $core = false) {
165
		return $this->include_common('css', $add, $mode, $core);
166
	}
167
	/**
168
	 * @param string          $what
169
	 * @param string|string[] $add
170
	 * @param string          $mode
171
	 * @param bool            $core
172
	 *
173
	 * @return \cs\Page
174
	 */
175
	protected function include_common ($what, $add, $mode, $core) {
176
		if (!$add) {
177
			return $this;
178
		}
179
		if (is_array($add)) {
180
			foreach (array_filter($add) as $style) {
181
				$this->include_common($what, $style, $mode, $core);
182
			}
183
		} else {
184
			if ($core) {
185
				$what = "core_$what";
186
			}
187
			$target = &$this->$what;
188
			if ($mode == 'file') {
189
				$target['path'][] = $add;
190
			} elseif ($mode == 'code') {
191
				$target['plain'] .= "$add\n";
192
			}
193
		}
194
		return $this;
195
	}
196
	/**
197
	 * Add config on page to make it available on frontend
198
	 *
199
	 * @param mixed  $config_structure        Any scalar type or array
200
	 * @param string $target                  Target is property of `window` object where config will be inserted as value, nested properties like `cs.sub.prop`
201
	 *                                        are supported and all nested properties are created on demand. It is recommended to use sub-properties of `cs`
202
	 *
203
	 * @return \cs\Page
204
	 */
205
	function config ($config_structure, $target) {
206
		return $this->config_internal($config_structure, $target);
207
	}
208
	/**
209
	 * @param mixed  $config_structure
210
	 * @param string $target
211
	 * @param bool   $core
212
	 *
213
	 * @return \cs\Page
214
	 */
215
	protected function config_internal ($config_structure, $target, $core = false) {
216
		$config = h::script(
217
			json_encode($config_structure, JSON_UNESCAPED_UNICODE),
218
			[
219
				'target' => $target,
220
				'class'  => 'cs-config',
221
				'type'   => 'application/json'
222
			]
223
		);
224
		if ($core) {
225
			$this->core_config .= $config;
226
		} else {
227
			$this->config .= $config;
228
		}
229
		return $this;
230
	}
231
	/**
232
	 * Getting of HTML, JS and CSS includes
233
	 *
234
	 * @return \cs\Page
235
	 */
236
	protected function add_includes_on_page () {
237
		$Config = Config::instance(true);
238
		if (!$Config) {
239
			return $this;
240
		}
241
		/**
242
		 * Base name for cache files
243
		 */
244
		$this->pcache_basename_path = PUBLIC_CACHE.'/'.$this->theme.'_'.Language::instance()->clang;
245
		/**
246
		 * Some JS configs required by system
247
		 */
248
		$this->add_system_configs();
249
		// TODO: I hope some day we'll get rid of this sh*t :(
250
		$this->ie_edge();
251
		$Request = Request::instance();
252
		/**
253
		 * If CSS and JavaScript compression enabled
254
		 */
255
		if ($Config->core['cache_compress_js_css'] && !($Request->admin_path && isset($Request->query['debug']))) {
256
			$this->webcomponents_polyfill($Request, true);
257
			list($includes, $preload) = $this->get_includes_and_preload_resource_for_page_with_compression($Config, $Request);
258
			$this->add_preload($preload);
259
		} else {
260
			$this->webcomponents_polyfill($Request, false);
261
			/**
262
			 * Language translation is added explicitly only when compression is disabled, otherwise it will be in compressed JS file
263
			 */
264
			$this->config_internal(Language::instance(), 'cs.Language', true);
265
			$this->config_internal($this->get_requirejs_paths(), 'requirejs.paths', true);
266
			$includes = $this->get_includes_for_page_without_compression($Config, $Request);
267
		}
268
		$this->css_internal($includes['css'], 'file', true);
269
		$this->js_internal($includes['js'], 'file', true);
270
		$this->html_internal($includes['html'], 'file', true);
271
		$this->add_includes_on_page_manually_added($Config);
272
		return $this;
273
	}
274
	/**
275
	 * Add JS polyfills for IE/Edge
276
	 */
277
	protected function ie_edge () {
278
		if (!preg_match('/Trident|Edge/', Request::instance()->header('user-agent'))) {
279
			return;
280
		}
281
		$this->js_internal(
282
			get_files_list(DIR."/includes/js/microsoft_sh*t", "/.*\\.js$/i", 'f', "includes/js/microsoft_sh*t", true),
283
			'file',
284
			true
285
		);
286
	}
287
	protected function add_system_configs () {
288
		$Config         = Config::instance();
289
		$Request        = Request::instance();
290
		$User           = User::instance();
291
		$current_module = $Request->current_module;
292
		$this->config_internal(
293
			[
294
				'base_url'              => $Config->base_url(),
295
				'current_base_url'      => $Config->base_url().'/'.($Request->admin_path ? 'admin/' : '').$current_module,
296
				'public_key'            => Core::instance()->public_key,
297
				'module'                => $current_module,
298
				'in_admin'              => (int)$Request->admin_path,
299
				'is_admin'              => (int)$User->admin(),
300
				'is_user'               => (int)$User->user(),
301
				'is_guest'              => (int)$User->guest(),
302
				'password_min_length'   => (int)$Config->core['password_min_length'],
303
				'password_min_strength' => (int)$Config->core['password_min_strength'],
304
				'debug'                 => (int)DEBUG,
305
				'route'                 => $Request->route,
306
				'route_path'            => $Request->route_path,
307
				'route_ids'             => $Request->route_ids
308
			],
309
			'cs',
310
			true
311
		);
312
		if ($User->admin()) {
313
			$this->config_internal((int)$Config->core['simple_admin_mode'], 'cs.simple_admin_mode', true);
314
		}
315
	}
316
	/**
317
	 * Hack: Add WebComponents Polyfill for browsers without native Shadow DOM support
318
	 *
319
	 * @param Request $Request
320
	 * @param bool    $with_compression
321
	 */
322
	protected function webcomponents_polyfill ($Request, $with_compression) {
323
		if ($Request->cookie('shadow_dom') == 1) {
324
			return;
325
		}
326
		$file = 'includes/js/WebComponents-polyfill/webcomponents-custom.min.js';
327
		if ($with_compression) {
328
			$compressed_file = PUBLIC_CACHE.'/webcomponents.js';
329
			if (!file_exists($compressed_file)) {
330
				$content = file_get_contents(DIR."/$file");
331
				file_put_contents($compressed_file, gzencode($content, 9), LOCK_EX | FILE_BINARY);
332
				file_put_contents("$compressed_file.hash", substr(md5($content), 0, 5));
333
			}
334
			$hash = file_get_contents("$compressed_file.hash");
335
			$this->js_internal("storage/pcache/webcomponents.js?$hash", 'file', true);
336
		} else {
337
			$this->js_internal($file, 'file', true);
338
		}
339
	}
340
	/**
341
	 * @param string[] $preload
342
	 */
343
	protected function add_preload ($preload) {
344
		$Response = Response::instance();
345
		foreach ($preload as $resource) {
346
			$extension = explode('?', file_extension($resource))[0];
347
			$as        = $this->extension_to_as[$extension];
348
			$resource  = str_replace(' ', '%20', $resource);
349
			$Response->header('Link', "<$resource>; rel=preload; as=$as'", false);
350
		}
351
	}
352
	/**
353
	 * @param Config  $Config
354
	 * @param Request $Request
355
	 *
356
	 * @return array[]
357
	 */
358
	protected function get_includes_and_preload_resource_for_page_with_compression ($Config, $Request) {
359
		/**
360
		 * Rebuilding HTML, JS and CSS cache if necessary
361
		 */
362
		if (!file_exists("$this->pcache_basename_path.json")) {
363
			list($dependencies, $includes_map) = $this->get_includes_dependencies_and_map($Config);
364
			$compressed_includes_map    = [];
365
			$not_embedded_resources_map = [];
366
			foreach ($includes_map as $filename_prefix => $local_includes) {
367
				// We replace `/` by `+` to make it suitable for filename
368
				$filename_prefix                           = str_replace('/', '+', $filename_prefix);
369
				$compressed_includes_map[$filename_prefix] = $this->cache_compressed_includes_files(
370
					"$this->pcache_basename_path:$filename_prefix",
371
					$local_includes,
372
					$Config->core['vulcanization'],
373
					$not_embedded_resources_map
374
				);
375
			}
376
			unset($includes_map, $filename_prefix, $local_includes);
377
			file_put_json("$this->pcache_basename_path.json", [$dependencies, $compressed_includes_map, array_filter($not_embedded_resources_map)]);
378
			Event::instance()->fire('System/Page/rebuild_cache');
379
		}
380
		list($dependencies, $compressed_includes_map, $not_embedded_resources_map) = file_get_json("$this->pcache_basename_path.json");
381
		$includes = $this->get_normalized_includes($dependencies, $compressed_includes_map, '+', $Request);
382
		$preload  = [];
383
		foreach (array_merge(...array_values($includes)) as $path) {
384
			$preload[] = ["/$path"];
385
			if (isset($not_embedded_resources_map[$path])) {
386
				$preload[] = $not_embedded_resources_map[$path];
387
			}
388
		}
389
		return [$includes, array_merge(...$preload)];
390
	}
391
	/**
392
	 * @param array      $dependencies
393
	 * @param string[][] $includes_map
394
	 * @param string     $separator `+` or `/`
395
	 * @param Request    $Request
396
	 *
397
	 * @return string[][]
398
	 */
399
	protected function get_normalized_includes ($dependencies, $includes_map, $separator, $Request) {
400
		$current_module = $Request->current_module;
401
		/**
402
		 * Current URL based on controller path (it better represents how page was rendered)
403
		 */
404
		$current_url = array_slice(App::instance()->controller_path, 1);
405
		$current_url = ($Request->admin_path ? "admin$separator" : '')."$current_module$separator".implode($separator, $current_url);
406
		/**
407
		 * Narrow the dependencies to current module only
408
		 */
409
		$dependencies    = array_unique(
410
			array_merge(
411
				['System'],
412
				$dependencies['System'],
413
				isset($dependencies[$current_module]) ? $dependencies[$current_module] : []
414
			)
415
		);
416
		$system_includes = [];
417
		// Array with empty array in order to avoid `array_merge()` failure later
418
		$dependencies_includes = array_fill_keys($dependencies, [[]]);
419
		$includes              = [];
420
		foreach ($includes_map as $path => $local_includes) {
421
			if ($path == 'System') {
422
				$system_includes = $local_includes;
423
			} elseif ($component = $this->get_dependency_component($dependencies, $path, $separator, $Request)) {
424
				/**
425
				 * @var string $component
426
				 */
427
				$dependencies_includes[$component][] = $local_includes;
428
			} elseif (mb_strpos($current_url, $path) === 0) {
429
				$includes[] = $local_includes;
430
			}
431
		}
432
		// Convert to indexed array first
433
		$dependencies_includes = array_values($dependencies_includes);
434
		// Flatten array on higher level
435
		$dependencies_includes = array_merge(...$dependencies_includes);
436
		return _array(array_merge_recursive($system_includes, ...$dependencies_includes, ...$includes));
437
	}
438
	/**
439
	 * @param array   $dependencies
440
	 * @param string  $url
441
	 * @param string  $separator `+` or `/`
442
	 * @param Request $Request
443
	 *
444
	 * @return false|string
445
	 */
446
	protected function get_dependency_component ($dependencies, $url, $separator, $Request) {
447
		$url_exploded = explode($separator, $url);
448
		/** @noinspection NestedTernaryOperatorInspection */
449
		$url_component = $url_exploded[0] != 'admin' ? $url_exploded[0] : (@$url_exploded[1] ?: '');
450
		$is_dependency =
451
			$url_component !== Config::SYSTEM_MODULE &&
452
			in_array($url_component, $dependencies) &&
453
			(
454
				$Request->admin_path || $Request->admin_path == ($url_exploded[0] == 'admin')
455
			);
456
		return $is_dependency ? $url_component : false;
457
	}
458
	/**
459
	 * @param Config  $Config
460
	 * @param Request $Request
461
	 *
462
	 * @return string[][]
463
	 */
464
	protected function get_includes_for_page_without_compression ($Config, $Request) {
465
		// To determine all dependencies and stuff we need `$Config` object to be already created
466
		list($dependencies, $includes_map) = $this->get_includes_dependencies_and_map($Config);
467
		$includes = $this->get_normalized_includes($dependencies, $includes_map, '/', $Request);
468
		return $this->add_versions_hash($this->absolute_path_to_relative($includes));
469
	}
470
	/**
471
	 * @param string[]|string[][] $path
472
	 *
473
	 * @return string[]|string[][]
474
	 */
475
	protected function absolute_path_to_relative ($path) {
476
		return _substr($path, strlen(DIR) + 1);
477
	}
478
	/**
479
	 * @param string[][] $includes
480
	 *
481
	 * @return string[][]
482
	 */
483
	protected function add_versions_hash ($includes) {
484
		$content     = array_reduce(
485
			get_files_list(DIR.'/components', '/^meta\.json$/', 'f', true, true),
486
			function ($content, $file) {
487
				return $content.file_get_contents($file);
488
			}
489
		);
490
		$content_md5 = substr(md5($content), 0, 5);
491
		foreach ($includes as &$files) {
492
			foreach ($files as &$file) {
493
				$file .= "?$content_md5";
494
			}
495
			unset($file);
496
		}
497
		return $includes;
498
	}
499
	/**
500
	 * @param Config $Config
501
	 */
502
	protected function add_includes_on_page_manually_added ($Config) {
503
		$configs = $this->core_config.$this->config;
504
		/** @noinspection NestedTernaryOperatorInspection */
505
		$styles =
506
			array_reduce(
507
				array_merge($this->core_css['path'], $this->css['path']),
508
				function ($content, $href) {
509
					return "$content<link href=\"/$href\" rel=\"stylesheet\" shim-shadowdom>\n";
510
				}
511
			).
512
			h::style($this->core_css['plain'].$this->css['plain'] ?: false);
513
		/** @noinspection NestedTernaryOperatorInspection */
514
		$scripts      =
515
			array_reduce(
516
				array_merge($this->core_js['path'], $this->js['path']),
517
				function ($content, $src) {
518
					return "$content<script src=\"/$src\"></script>\n";
519
				}
520
			).
521
			h::script($this->core_js['plain'].$this->js['plain'] ?: false);
522
		$html_imports =
523
			array_reduce(
524
				array_merge($this->core_html['path'], $this->html['path']),
525
				function ($content, $href) {
526
					return "$content<link href=\"/$href\" rel=\"import\">\n";
527
				}
528
			).
529
			$this->core_html['plain'].$this->html['plain'];
530
		$this->Head .= $configs.$styles;
531
		if ($Config->core['put_js_after_body']) {
532
			$this->post_Body .= $scripts.$html_imports;
533
		} else {
534
			$this->Head .= $scripts.$html_imports;
535
		}
536
	}
537
}
538