xref: /webtrees/app/View.php (revision 4c9729fb95eef184fc375252b45de126173ab3e4)
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