xref: /webtrees/app/View.php (revision afa67798854828b1edc33dd077960ec2b18e6140)
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     * Create 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