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