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