xref: /webtrees/app/View.php (revision 52672e95f9626642591e18327ff0eaa0bf5c2806)
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 NAMESPACE_SEPARATOR = '::';
50
51    private const 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