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