Completed
Push — master ( e4776a...9f513b )
by Nazar
03:59
created

Includes::absolute_path_to_relative()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

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