xref: /webtrees/app/View.php (revision 3e4bf26f9f16d92152484c56e3629888fb6019d5)
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_start;
32use function sha1;
33
34/**
35 * Simple view/template class.
36 */
37class View
38{
39    public const NAMESPACE_SEPARATOR = '::';
40
41    // File extension for our template files.
42    private const TEMPLATE_EXTENSION = '.phtml';
43
44    /**
45     * @var string The (file) name of the view.
46     */
47    private $name;
48
49    /**
50     * @var mixed[] Data to be inserted into the view.
51     */
52    private $data;
53
54    /**
55     * @var string[] Where do the templates live, for each namespace.
56     */
57    private static $namespaces = [
58        '' => WT_ROOT . 'resources/views/',
59    ];
60
61    /**
62     * @var string[] Modules can replace core views with their own.
63     */
64    private static $replacements = [];
65
66    /**
67     * @var mixed[] Data to be inserted into all views.
68     */
69    private static $shared_data = [];
70
71    /**
72     * @var string Implementation of Blade "stacks".
73     */
74    private static $stack;
75
76    /**
77     * @var array[] Implementation of Blade "stacks".
78     */
79    private static $stacks = [];
80
81    /**
82     * Createa view from a template name and optional data.
83     *
84     * @param string $name
85     * @param array  $data
86     */
87    public function __construct(string $name, $data = [])
88    {
89        $this->name = $name;
90        $this->data = $data;
91    }
92
93    /**
94     * Shared data that is available to all views.
95     *
96     * @param string $key
97     * @param mixed  $value
98     *
99     * @return void
100     */
101    public static function share(string $key, $value)
102    {
103        self::$shared_data[$key] = $value;
104    }
105
106    /**
107     * Implementation of Blade "stacks".
108     *
109     * @see https://laravel.com/docs/5.5/blade#stacks
110     *
111     * @param string $stack
112     *
113     * @return void
114     */
115    public static function push(string $stack)
116    {
117        self::$stack = $stack;
118
119        ob_start();
120    }
121
122    /**
123     * Implementation of Blade "stacks".
124     *
125     * @return void
126     */
127    public static function endpush()
128    {
129        $content = ob_get_clean();
130
131        self::$stacks[self::$stack][] = $content;
132    }
133
134    /**
135     * Variant of push that will only add one copy of each item.
136     *
137     * @param string $stack
138     *
139     * @return void
140     */
141    public static function pushunique(string $stack)
142    {
143        self::$stack = $stack;
144
145        ob_start();
146    }
147
148    /**
149     * Variant of push that will only add one copy of each item.
150     *
151     * @return void
152     */
153    public static function endpushunique()
154    {
155        $content = ob_get_clean();
156
157        self::$stacks[self::$stack][sha1($content)] = $content;
158    }
159
160    /**
161     * Implementation of Blade "stacks".
162     *
163     * @param string $stack
164     *
165     * @return string
166     */
167    public static function stack(string $stack): string
168    {
169        $content = implode('', self::$stacks[$stack] ?? []);
170
171        self::$stacks[$stack] = [];
172
173        return $content;
174    }
175
176    /**
177     * Render a view.
178     *
179     * @return string
180     * @throws Throwable
181     */
182    public function render(): string
183    {
184        $variables_for_view = $this->data + self::$shared_data;
185        extract($variables_for_view);
186
187        try {
188            ob_start();
189            // Do not use require, so we can catch errors for missing files
190            include $this->getFilenameForView($this->name);
191
192            return ob_get_clean();
193        } catch (Throwable $ex) {
194            ob_end_clean();
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::endsWith($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 (!Str::contains($view_name, self::NAMESPACE_SEPARATOR)) {
244            $view_name = self::NAMESPACE_SEPARATOR . $view_name;
245        }
246
247        // Apply replacements / customisations
248        while (array_key_exists($view_name, self::$replacements)) {
249            $view_name = self::$replacements[$view_name];
250        }
251
252        [$namespace, $view_name] = explode(self::NAMESPACE_SEPARATOR, $view_name, 2);
253
254        if ((self::$namespaces[$namespace] ?? null) === null) {
255            throw new RuntimeException('Namespace "' . e($namespace) .  '" not found.');
256        }
257
258        $view_file = self::$namespaces[$namespace] . $view_name . self::TEMPLATE_EXTENSION;
259
260        if (!is_file($view_file)) {
261            throw new RuntimeException('View file not found: ' . e($view_file));
262        }
263
264        return $view_file;
265    }
266
267    /**
268     * Cerate and render a view in a single operation.
269     *
270     * @param string  $name
271     * @param mixed[] $data
272     *
273     * @return string
274     */
275    public static function make($name, $data = []): string
276    {
277        $view = new static($name, $data);
278
279        DebugBar::addView($name, $data);
280
281        return $view->render();
282    }
283}
284