1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2019 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees; 19 20use Exception; 21use Illuminate\Support\Str; 22use InvalidArgumentException; 23use RuntimeException; 24use Throwable; 25use function array_key_exists; 26use function explode; 27use function extract; 28use function implode; 29use function is_file; 30use function ob_end_clean; 31use function ob_get_level; 32use function ob_start; 33use function sha1; 34 35/** 36 * Simple view/template class. 37 */ 38class View 39{ 40 public const NAMESPACE_SEPARATOR = '::'; 41 42 // File extension for our template files. 43 private const TEMPLATE_EXTENSION = '.phtml'; 44 45 /** 46 * @var string The (file) name of the view. 47 */ 48 private $name; 49 50 /** 51 * @var mixed[] Data to be inserted into the view. 52 */ 53 private $data; 54 55 /** 56 * @var string[] Where do the templates live, for each namespace. 57 */ 58 private static $namespaces = [ 59 '' => Webtrees::ROOT_DIR . 'resources/views/', 60 ]; 61 62 /** 63 * @var string[] Modules can replace core views with their own. 64 */ 65 private static $replacements = []; 66 67 /** 68 * @var mixed[] Data to be inserted into all views. 69 */ 70 private static $shared_data = []; 71 72 /** 73 * @var string Implementation of Blade "stacks". 74 */ 75 private static $stack; 76 77 /** 78 * @var array[] Implementation of Blade "stacks". 79 */ 80 private static $stacks = []; 81 82 /** 83 * Createa view from a template name and optional data. 84 * 85 * @param string $name 86 * @param array $data 87 */ 88 public function __construct(string $name, $data = []) 89 { 90 $this->name = $name; 91 $this->data = $data; 92 } 93 94 /** 95 * Shared data that is available to all views. 96 * 97 * @param string $key 98 * @param mixed $value 99 * 100 * @return void 101 */ 102 public static function share(string $key, $value): void 103 { 104 self::$shared_data[$key] = $value; 105 } 106 107 /** 108 * Implementation of Blade "stacks". 109 * 110 * @see https://laravel.com/docs/5.5/blade#stacks 111 * 112 * @param string $stack 113 * 114 * @return void 115 */ 116 public static function push(string $stack): void 117 { 118 self::$stack = $stack; 119 120 ob_start(); 121 } 122 123 /** 124 * Implementation of Blade "stacks". 125 * 126 * @return void 127 */ 128 public static function endpush(): void 129 { 130 $content = ob_get_clean(); 131 132 self::$stacks[self::$stack][] = $content; 133 } 134 135 /** 136 * Variant of push that will only add one copy of each item. 137 * 138 * @param string $stack 139 * 140 * @return void 141 */ 142 public static function pushunique(string $stack): void 143 { 144 self::$stack = $stack; 145 146 ob_start(); 147 } 148 149 /** 150 * Variant of push that will only add one copy of each item. 151 * 152 * @return void 153 */ 154 public static function endpushunique(): void 155 { 156 $content = ob_get_clean(); 157 158 self::$stacks[self::$stack][sha1($content)] = $content; 159 } 160 161 /** 162 * Implementation of Blade "stacks". 163 * 164 * @param string $stack 165 * 166 * @return string 167 */ 168 public static function stack(string $stack): string 169 { 170 $content = implode('', self::$stacks[$stack] ?? []); 171 172 self::$stacks[$stack] = []; 173 174 return $content; 175 } 176 177 /** 178 * Render a view. 179 * 180 * @return string 181 * @throws Throwable 182 */ 183 public function render(): string 184 { 185 $variables_for_view = $this->data + self::$shared_data; 186 extract($variables_for_view, EXTR_SKIP); 187 188 try { 189 ob_start(); 190 // Do not use require, so we can catch errors for missing files 191 include $this->getFilenameForView($this->name); 192 193 return ob_get_clean(); 194 } catch (Throwable $ex) { 195 while (ob_get_level() > 0) { 196 ob_end_clean(); 197 } 198 throw $ex; 199 } 200 } 201 202 /** 203 * @param string $namespace 204 * @param string $path 205 * 206 * @throws InvalidArgumentException 207 */ 208 public static function registerNamespace(string $namespace, string $path): void 209 { 210 if ($namespace === '') { 211 throw new InvalidArgumentException('Cannot register the default namespace'); 212 } 213 214 if (!Str::endsWith($path, '/')) { 215 throw new InvalidArgumentException('Paths must end with a directory separator'); 216 } 217 218 self::$namespaces[$namespace] = $path; 219 } 220 221 /** 222 * @param string $old 223 * @param string $new 224 * 225 * @throws InvalidArgumentException 226 */ 227 public static function registerCustomView(string $old, string $new): void 228 { 229 if (Str::contains($old, self::NAMESPACE_SEPARATOR) && Str::contains($new, self::NAMESPACE_SEPARATOR)) { 230 self::$replacements[$old] = $new; 231 } else { 232 throw new InvalidArgumentException(); 233 } 234 } 235 236 /** 237 * Find the file for a view. 238 * 239 * @param string $view_name 240 * 241 * @return string 242 * @throws Exception 243 */ 244 public function getFilenameForView(string $view_name): string 245 { 246 // If we request "::view", then use it explicityly. Don't allow replacements. 247 $explicit = Str::startsWith($view_name, self::NAMESPACE_SEPARATOR); 248 249 if (!Str::contains($view_name, self::NAMESPACE_SEPARATOR)) { 250 $view_name = self::NAMESPACE_SEPARATOR . $view_name; 251 } 252 253 // Apply replacements / customisations 254 while (!$explicit && array_key_exists($view_name, self::$replacements)) { 255 $view_name = self::$replacements[$view_name]; 256 } 257 258 [$namespace, $view_name] = explode(self::NAMESPACE_SEPARATOR, $view_name, 2); 259 260 if ((self::$namespaces[$namespace] ?? null) === null) { 261 throw new RuntimeException('Namespace "' . e($namespace) . '" not found.'); 262 } 263 264 $view_file = self::$namespaces[$namespace] . $view_name . self::TEMPLATE_EXTENSION; 265 266 if (!is_file($view_file)) { 267 throw new RuntimeException('View file not found: ' . e($view_file)); 268 } 269 270 return $view_file; 271 } 272 273 /** 274 * Cerate and render a view in a single operation. 275 * 276 * @param string $name 277 * @param mixed[] $data 278 * 279 * @return string 280 */ 281 public static function make($name, $data = []): string 282 { 283 $view = new static($name, $data); 284 285 DebugBar::addView($name, $data); 286 287 return $view->render(); 288 } 289} 290