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