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