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