xref: /webtrees/app/View.php (revision 74d6dc0ec259c643834b111577684e38e74234c8)
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        '' => Webtrees::ROOT_DIR . '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): void
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): void
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(): void
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): void
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(): void
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, EXTR_SKIP);
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 we request "::view", then use it explicityly.  Don't allow replacements.
244        $explicit = Str::startsWith($view_name, self::NAMESPACE_SEPARATOR);
245
246        if (!Str::contains($view_name, self::NAMESPACE_SEPARATOR)) {
247            $view_name = self::NAMESPACE_SEPARATOR . $view_name;
248        }
249
250        // Apply replacements / customisations
251        while (!$explicit && array_key_exists($view_name, self::$replacements)) {
252            $view_name = self::$replacements[$view_name];
253        }
254
255        [$namespace, $view_name] = explode(self::NAMESPACE_SEPARATOR, $view_name, 2);
256
257        if ((self::$namespaces[$namespace] ?? null) === null) {
258            throw new RuntimeException('Namespace "' . e($namespace) .  '" not found.');
259        }
260
261        $view_file = self::$namespaces[$namespace] . $view_name . self::TEMPLATE_EXTENSION;
262
263        if (!is_file($view_file)) {
264            throw new RuntimeException('View file not found: ' . e($view_file));
265        }
266
267        return $view_file;
268    }
269
270    /**
271     * Cerate and render a view in a single operation.
272     *
273     * @param string  $name
274     * @param mixed[] $data
275     *
276     * @return string
277     */
278    public static function make($name, $data = []): string
279    {
280        $view = new static($name, $data);
281
282        DebugBar::addView($name, $data);
283
284        return $view->render();
285    }
286}
287