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