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