1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2019 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17declare(strict_types=1); 18 19namespace Fisharebest\Webtrees; 20 21use Exception; 22use Illuminate\Support\Str; 23use InvalidArgumentException; 24use RuntimeException; 25use Throwable; 26 27use function array_key_exists; 28use function explode; 29use function extract; 30use function implode; 31use function is_file; 32use function ob_end_clean; 33use function ob_get_level; 34use function ob_start; 35use function sha1; 36 37/** 38 * Simple view/template class. 39 */ 40class View 41{ 42 public const NAMESPACE_SEPARATOR = '::'; 43 44 // File extension for our template files. 45 private const TEMPLATE_EXTENSION = '.phtml'; 46 47 /** 48 * @var string The (file) name of the view. 49 */ 50 private $name; 51 52 /** 53 * @var mixed[] Data to be inserted into the view. 54 */ 55 private $data; 56 57 /** 58 * @var string[] Where do the templates live, for each namespace. 59 */ 60 private static $namespaces = [ 61 '' => Webtrees::ROOT_DIR . 'resources/views/', 62 ]; 63 64 /** 65 * @var string[] Modules can replace core views with their own. 66 */ 67 private static $replacements = []; 68 69 /** 70 * @var mixed[] Data to be inserted into all views. 71 */ 72 private static $shared_data = []; 73 74 /** 75 * @var string Implementation of Blade "stacks". 76 */ 77 private static $stack; 78 79 /** 80 * @var array[] Implementation of Blade "stacks". 81 */ 82 private static $stacks = []; 83 84 /** 85 * Createa view from a template name and optional data. 86 * 87 * @param string $name 88 * @param array $data 89 */ 90 public function __construct(string $name, $data = []) 91 { 92 $this->name = $name; 93 $this->data = $data; 94 } 95 96 /** 97 * Shared data that is available to all views. 98 * 99 * @param string $key 100 * @param mixed $value 101 * 102 * @return void 103 */ 104 public static function share(string $key, $value): void 105 { 106 self::$shared_data[$key] = $value; 107 } 108 109 /** 110 * Implementation of Blade "stacks". 111 * 112 * @see https://laravel.com/docs/5.5/blade#stacks 113 * 114 * @param string $stack 115 * 116 * @return void 117 */ 118 public static function push(string $stack): void 119 { 120 self::$stack = $stack; 121 122 ob_start(); 123 } 124 125 /** 126 * Implementation of Blade "stacks". 127 * 128 * @return void 129 */ 130 public static function endpush(): void 131 { 132 $content = ob_get_clean(); 133 134 self::$stacks[self::$stack][] = $content; 135 } 136 137 /** 138 * Variant of push that will only add one copy of each item. 139 * 140 * @param string $stack 141 * 142 * @return void 143 */ 144 public static function pushunique(string $stack): void 145 { 146 self::$stack = $stack; 147 148 ob_start(); 149 } 150 151 /** 152 * Variant of push that will only add one copy of each item. 153 * 154 * @return void 155 */ 156 public static function endpushunique(): void 157 { 158 $content = ob_get_clean(); 159 160 self::$stacks[self::$stack][sha1($content)] = $content; 161 } 162 163 /** 164 * Implementation of Blade "stacks". 165 * 166 * @param string $stack 167 * 168 * @return string 169 */ 170 public static function stack(string $stack): string 171 { 172 $content = implode('', self::$stacks[$stack] ?? []); 173 174 self::$stacks[$stack] = []; 175 176 return $content; 177 } 178 179 /** 180 * Render a view. 181 * 182 * @return string 183 * @throws Throwable 184 */ 185 public function render(): string 186 { 187 $variables_for_view = $this->data + self::$shared_data; 188 extract($variables_for_view, EXTR_SKIP); 189 190 try { 191 ob_start(); 192 // Do not use require, so we can catch errors for missing files 193 include $this->getFilenameForView($this->name); 194 195 return ob_get_clean(); 196 } catch (Throwable $ex) { 197 while (ob_get_level() > 0) { 198 ob_end_clean(); 199 } 200 throw $ex; 201 } 202 } 203 204 /** 205 * @param string $namespace 206 * @param string $path 207 * 208 * @throws InvalidArgumentException 209 */ 210 public static function registerNamespace(string $namespace, string $path): void 211 { 212 if ($namespace === '') { 213 throw new InvalidArgumentException('Cannot register the default namespace'); 214 } 215 216 if (!Str::endsWith($path, '/')) { 217 throw new InvalidArgumentException('Paths must end with a directory separator'); 218 } 219 220 self::$namespaces[$namespace] = $path; 221 } 222 223 /** 224 * @param string $old 225 * @param string $new 226 * 227 * @throws InvalidArgumentException 228 */ 229 public static function registerCustomView(string $old, string $new): void 230 { 231 if (Str::contains($old, self::NAMESPACE_SEPARATOR) && Str::contains($new, self::NAMESPACE_SEPARATOR)) { 232 self::$replacements[$old] = $new; 233 } else { 234 throw new InvalidArgumentException(); 235 } 236 } 237 238 /** 239 * Find the file for a view. 240 * 241 * @param string $view_name 242 * 243 * @return string 244 * @throws Exception 245 */ 246 public function getFilenameForView(string $view_name): string 247 { 248 // If we request "::view", then use it explicityly. Don't allow replacements. 249 $explicit = Str::startsWith($view_name, self::NAMESPACE_SEPARATOR); 250 251 if (!Str::contains($view_name, self::NAMESPACE_SEPARATOR)) { 252 $view_name = self::NAMESPACE_SEPARATOR . $view_name; 253 } 254 255 // Apply replacements / customisations 256 while (!$explicit && array_key_exists($view_name, self::$replacements)) { 257 $view_name = self::$replacements[$view_name]; 258 } 259 260 [$namespace, $view_name] = explode(self::NAMESPACE_SEPARATOR, $view_name, 2); 261 262 if ((self::$namespaces[$namespace] ?? null) === null) { 263 throw new RuntimeException('Namespace "' . e($namespace) . '" not found.'); 264 } 265 266 $view_file = self::$namespaces[$namespace] . $view_name . self::TEMPLATE_EXTENSION; 267 268 if (!is_file($view_file)) { 269 throw new RuntimeException('View file not found: ' . e($view_file)); 270 } 271 272 return $view_file; 273 } 274 275 /** 276 * Cerate and render a view in a single operation. 277 * 278 * @param string $name 279 * @param mixed[] $data 280 * 281 * @return string 282 */ 283 public static function make($name, $data = []): string 284 { 285 $view = new static($name, $data); 286 287 DebugBar::addView($name, $data); 288 289 return $view->render(); 290 } 291} 292