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