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