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