Completed
Pull Request — master (#24)
by Todd
02:49
created

src/Ebi.php (1 issue)

Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2017 Vanilla Forums Inc.
5
 * @license MIT
6
 */
7
8
namespace Ebi;
9
10
11
class Ebi {
12
    /**
13
     * @var string
14
     */
15
    protected $cachePath;
16
    /**
17
     * @var callable[]
18
     */
19
    protected $functions;
20
    /**
21
     * @var TemplateLoaderInterface
22
     */
23
    private $templateLoader;
24
    /**
25
     * @var CompilerInterface
26
     */
27
    private $compiler;
28
    /**
29
     * @var callable[]
30
     */
31
    private $components = [];
32
    /**
33
     * @var array
34
     */
35
    private $meta;
36
37
    /**
38
     * Ebi constructor.
39
     *
40
     * @param TemplateLoaderInterface $templateLoader Used to load template sources from component names.
41
     * @param string $cachePath The path to cache compiled templates.
42
     * @param CompilerInterface $compiler The compiler used to compile templates.
43
     */
44 68
    public function __construct(TemplateLoaderInterface $templateLoader, $cachePath, CompilerInterface $compiler = null) {
45 68
        $this->templateLoader = $templateLoader;
46 68
        $this->cachePath = $cachePath;
47 68
        $this->compiler = $compiler ?: new Compiler();
48
49 68
        $this->defineFunction('abs');
50 68
        $this->defineFunction('arrayColumn', 'array_column');
51 68
        $this->defineFunction('arrayKeyExists', 'array_key_exists');
52 68
        $this->defineFunction('arrayKeys', 'array_keys');
53 68
        $this->defineFunction('arrayMerge', 'array_merge');
54 68
        $this->defineFunction('arrayMergeRecursive', 'array_merge_recursive');
55 68
        $this->defineFunction('arrayReplace', 'array_replace');
56 68
        $this->defineFunction('arrayReplaceRecursive', 'array_replace_recursive');
57 68
        $this->defineFunction('arrayReverse', 'array_reverse');
58 68
        $this->defineFunction('arrayValues', 'array_values');
59 68
        $this->defineFunction('base64Encode', 'base64_encode');
60 68
        $this->defineFunction('ceil');
61 68
        $this->defineFunction('componentExists', [$this, 'componentExists']);
62 68
        $this->defineFunction('count');
63 68
        $this->defineFunction('empty');
64 68
        $this->defineFunction('floor');
65 68
        $this->defineFunction('formatDate', [$this, 'formatDate']);
66 68
        $this->defineFunction('formatNumber', 'number_format');
67 68
        $this->defineFunction('htmlEncode', 'htmlspecialchars');
68 68
        $this->defineFunction('join');
69 68
        $this->defineFunction('lcase', $this->mb('strtolower'));
70 68
        $this->defineFunction('lcfirst');
71 68
        $this->defineFunction('ltrim');
72 68
        $this->defineFunction('max');
73 68
        $this->defineFunction('min');
74 68
        $this->defineFunction('queryEncode', 'http_build_query');
75 68
        $this->defineFunction('round');
76 68
        $this->defineFunction('rtrim');
77 68
        $this->defineFunction('sprintf');
78 68
        $this->defineFunction('strlen', $this->mb('strlen'));
79 68
        $this->defineFunction('substr', $this->mb('substr'));
80 68
        $this->defineFunction('trim');
81 68
        $this->defineFunction('ucase', $this->mb('strtoupper'));
82 68
        $this->defineFunction('ucfirst');
83 68
        $this->defineFunction('ucwords');
84 68
        $this->defineFunction('urlencode', 'rawurlencode');
85
86 68
        $this->defineFunction('@class', [$this, 'attributeClass']);
87
88
        // Define a simple component not found component to help troubleshoot.
89 68
        $this->defineComponent('@component-not-found', function ($props) {
90 1
            echo '<!-- Ebi component "'.htmlspecialchars($props['component']).'" not found. -->';
91 68
        });
92
93
        // Define a simple component exception.
94 68
        $this->defineComponent('@exception', function ($props) {
95 1
            echo "\n<!--\nEbi exception in component \"".htmlspecialchars($props['component'])."\".\n".
96 1
                htmlspecialchars($props['message'])."\n-->\n";
97
98 68
        });
99
100 68
        $this->defineComponent('@compile-exception', [$this, 'writeCompileException']);
101 68
    }
102
103
    /**
104
     * Register a runtime function.
105
     *
106
     * @param string $name The name of the function.
107
     * @param callable $function The function callback.
108
     */
109 68
    public function defineFunction($name, $function = null) {
110 68
        if ($function === null) {
111 68
            $function = $name;
112
        }
113
114 68
        $this->functions[strtolower($name)] = $function;
115 68
        $this->compiler->defineFunction($name, $function);
116 68
    }
117
118 68
    private function mb($func) {
119 68
        return function_exists("mb_$func") ? "mb_$func" : $func;
120
    }
121
122
    /**
123
     * Write a component to the output buffer.
124
     *
125
     * @param string $component The name of the component.
126
     * @param array ...$args
127
     */
128 19
    public function write($component, ...$args) {
129 19
        $component = strtolower($component);
130
131
        try {
132 19
            $callback = $this->lookup($component);
133
134 19
            if (is_callable($callback)) {
135 19
                call_user_func($callback, ...$args);
136
            } else {
137 19
                $this->write('@component-not-found', ['component' => $component]);
138
            }
139 1
        } catch (\Throwable $ex) {
140 1
            $this->write('@exception', ['message' => $ex->getMessage(), 'code', $ex->getCode(), 'component' => $component]);
141 1
            return;
142
        } catch (\Exception $ex) {
143
            $this->write('@exception', ['message' => $ex->getMessage(), 'code', $ex->getCode(), 'component' => $component]);
144
            return;
145
        }
146 19
    }
147
148
    /**
149
     * Lookup a component with a given name.
150
     *
151
     * @param string $component The component to lookup.
152
     * @return callable|null Returns the component function or **null** if the component is not found.
153
     */
154 63
    public function lookup($component) {
155 63
        $component = strtolower($component);
156 63
        $key = $this->componentKey($component);
157
158 63
        if (!array_key_exists($key, $this->components)) {
159 58
            $this->loadComponent($component);
160
        }
161
162 63
        if (isset($this->components[$key])) {
163 62
            return $this->components[$key];
164
        } else {
165
            // Mark a tombstone to the component array so it doesn't keep getting loaded.
166 2
            $this->components[$key] = null;
167 2
            return null;
168
        }
169
    }
170
171
    /**
172
     * Check to see if a component exists.
173
     *
174
     * @param string $component The name of the component.
175
     * @param bool $loader Whether or not to use the component loader or just look in the component cache.
176
     * @return bool Returns **true** if the component exists or **false** otherwise.
177
     */
178 2
    public function componentExists($component, $loader = true) {
179 2
        $componentKey = $this->componentKey($component);
180 2
        if (array_key_exists($componentKey, $this->components)) {
181 1
            return $this->components[$componentKey] !== null;
182 2
        } elseif ($loader) {
183 2
            return !empty($this->templateLoader->cacheKey($component));
184
        }
185 1
        return false;
186
    }
187
188
    /**
189
     * Strip the namespace off a component name to get the component key.
190
     *
191
     * @param string $component The full name of the component with a possible namespace.
192
     * @return string Returns the component key.
193
     */
194 65
    protected function componentKey($component) {
195 65
        if (false !== $pos = strpos($component, ':')) {
196 1
            $component = substr($component, $pos + 1);
197
        }
198 65
        return strtolower($component);
199
    }
200
201
    /**
202
     * Load a component.
203
     *
204
     * @param string $component The name of the component to load.
205
     * @return callable|null Returns the component or **null** if the component isn't found.
206
     */
207 58
    protected function loadComponent($component) {
208 58
        $cacheKey = $this->templateLoader->cacheKey($component);
209
        // The template loader can tell us a template doesn't exist when giving the cache key.
210 58
        if (empty($cacheKey)) {
211 2
            return null;
212
        }
213
214 56
        $cachePath = "{$this->cachePath}/$cacheKey.php";
215 56
        $componentKey = $this->componentKey($component);
216
217 56
        if (!file_exists($cachePath)) {
218 56
            $src = $this->templateLoader->load($component);
219
            try {
220 56
                return $this->compile($componentKey, $src, $cacheKey);
221 6
            } catch (CompileException $ex) {
222 6
                $props = ['message' => $ex->getMessage()] + $ex->getContext();
223 6
                return $this->components[$componentKey] = function() use ($props) {
224 6
                    $this->write('@compile-exception', $props);
225 6
                };
226
            }
227
        } else {
228
            return $this->includeComponent($componentKey, $cachePath);
229
        }
230
    }
231
232 6
    protected function writeCompileException($props) {
233 6
        echo "\n<section class=\"ebi-ex\">\n",
234 6
            '<h2>Error compiling '.htmlspecialchars($props['path'])." near line {$props['line']}.</h2>\n";
235
236 6
        echo '<p class="ebi-ex-message">'.htmlspecialchars($props['message'])."</p>\n";
237
238 6
        if (!empty($props['source'])) {
239 6
            $source = $props['source'];
240 6
            if (isset($props['sourcePosition'])) {
241 3
                $pos = $props['sourcePosition'];
242 3
                $len = isset($props['sourceLength']) ? $props['sourceLength'] : 1;
243
244 3
                if ($len === 1) {
245
                    // Small kludge to select a viewable character.
246 3
                    for (; $pos >= 0 && isset($source[$pos]) && in_array($source[$pos], [' ', "\n"], true); $pos--, $len++) {
0 ignored issues
show
This for loop is empty and can be removed.

This check looks for for loops that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

Consider removing the loop.

Loading history...
247
                        // It's all in the loop.
248
                    }
249
                }
250
251 3
                $source = htmlspecialchars(substr($source, 0, $pos)).
252 3
                    '<mark class="ebi-ex-highlight">'.htmlspecialchars(substr($source, $pos, $len)).'</mark>'.
253 3
                    htmlspecialchars(substr($source, $pos + $len));
254
            } else {
255 3
                $source = htmlspecialchars($source);
256
            }
257
258 6
            echo '<pre class="ebi-ex-source ebi-ex-context"><code>',
259 6
                $source,
260 6
                "</code></pre>\n";
261
        }
262
263 6
        if (!empty($props['lines'])) {
264 6
            echo '<pre class="ebi-ex-source ebi-ex-lines">';
265
266 6
            foreach ($props['lines'] as $i => $line) {
267 6
                echo '<code class="ebi-ex-line">';
268
269 6
                $str = sprintf("%3d. %s", $i, htmlspecialchars($line));
270 6
                if ($i === $props['line']) {
271 6
                    echo "<mark class=\"ebi-ex-highlight\">$str</mark>";
272
                } else {
273 4
                    echo $str;
274
                }
275
276 6
                echo "</code>\n";
277
            }
278
279 6
            echo "</pre>\n";
280
        }
281
282 6
        echo "</section>\n";
283 6
    }
284
285
    /**
286
     * Check to see if a specific cache key exists in the cache.
287
     *
288
     * @param string $cacheKey The cache key to check.
289
     * @return bool Returns **true** if there is a cache key at the file or **false** otherwise.
290
     */
291 1
    public function cacheKeyExists($cacheKey) {
292 1
        $cachePath = "{$this->cachePath}/$cacheKey.php";
293 1
        return file_exists($cachePath);
294
    }
295
296
    /**
297
     * Compile a component from source, cache it and include it.
298
     *
299
     * @param string $component The name of the component.
300
     * @param string $src The component source.
301
     * @param string $cacheKey The cache key of the component.
302
     * @return callable|null Returns the compiled component closure.
303
     */
304 60
    public function compile($component, $src, $cacheKey) {
305 60
        $cachePath = "{$this->cachePath}/$cacheKey.php";
306 60
        $component = strtolower($component);
307
308 60
        $php = $this->compiler->compile($src, ['basename' => $component, 'path' => $cacheKey]);
309 54
        $comment = "/*\n".str_replace('*/', '❄/', trim($src))."\n*/";
310
311 54
        $this->filePutContents($cachePath, "<?php\n$comment\n$php");
312
313 54
        return $this->includeComponent($component, $cachePath);
314
    }
315
316
    /**
317
     * Include a cached component.
318
     *
319
     * @param string $component The component key.
320
     * @param string $cachePath The path to the component.
321
     * @return callable|null Returns the component function or **null** if the component wasn't properly defined.
322
     */
323 54
    private function includeComponent($component, $cachePath) {
324 54
        unset($this->components[$component]);
325 54
        $fn = $this->requireFile($cachePath);
326
327 54
        if (isset($this->components[$component])) {
328 54
            return $this->components[$component];
329
        } elseif (is_callable($fn)) {
330
            $this->defineComponent($component, $fn);
331
            return $fn;
332
        } else {
333
            $this->components[$component] = null;
334
            return null;
335
        }
336
    }
337
338
    /**
339
     * A safe version of {@link file_put_contents()} that also clears op caches.
340
     *
341
     * @param string $path The path to save to.
342
     * @param string $contents The contents of the file.
343
     * @return bool Returns **true** on success or **false** on failure.
344
     */
345 54
    private function filePutContents($path, $contents) {
346 54
        if (!file_exists(dirname($path))) {
347 4
            mkdir(dirname($path), 0777, true);
348
        }
349 54
        $tmpPath = tempnam(dirname($path), 'ebi-');
350 54
        $r = false;
351 54
        if (file_put_contents($tmpPath, $contents) !== false) {
352 54
            chmod($tmpPath, 0664);
353 54
            $r = rename($tmpPath, $path);
354
        }
355
356 54
        if (function_exists('apc_delete_file')) {
357
            // This fixes a bug with some configurations of apc.
358
            @apc_delete_file($path);
359 54
        } elseif (function_exists('opcache_invalidate')) {
360 54
            @opcache_invalidate($path);
361
        }
362
363 54
        return $r;
364
    }
365
366
    /**
367
     * Include a file.
368
     *
369
     * This is method is useful for including a file bound to this object instance.
370
     *
371
     * @param string $path The path to the file to include.
372
     * @return mixed Returns the result of the include.
373
     */
374 54
    public function requireFile($path) {
375 54
        return require $path;
376
    }
377
378
    /**
379
     * Register a component.
380
     *
381
     * @param string $name The name of the component to register.
382
     * @param callable $component The component function.
383
     */
384 68
    public function defineComponent($name, callable $component) {
385 68
        $this->components[$name] = $component;
386 68
    }
387
388
    /**
389
     * Render a component to a string.
390
     *
391
     * @param string $component The name of the component to render.
392
     * @param array ...$args Arguments to pass to the component.
393
     * @return string|null Returns the rendered component or **null** if the component was not found.
394
     */
395 59
    public function render($component, ...$args) {
396 59
        if ($callback = $this->lookup($component)) {
397 59
            ob_start();
398 59
            $errs = error_reporting(error_reporting() & ~E_NOTICE & ~E_WARNING);
399 59
            call_user_func($callback, ...$args);
400 59
            error_reporting($errs);
401 59
            $str = ob_get_clean();
402 59
            return $str;
403
        } else {
404
            trigger_error("Could not find component $component.", E_USER_NOTICE);
405
            return null;
406
        }
407
    }
408
409
    /**
410
     * Set the error reporting appropriate for template rendering.
411
     *
412
     * @return int Returns the previous error level.
413
     */
414
    public function setErrorReporting() {
415
        $errs = error_reporting(error_reporting() & ~E_NOTICE & ~E_WARNING);
416
        return $errs;
417
    }
418
419
    /**
420
     * Call a function registered with **defineFunction()**.
421
     *
422
     * If a static or global function is registered then it's simply rendered in the compiled template.
423
     * This method is for closures or callbacks.
424
     *
425
     * @param string $name The name of the registered function.
426
     * @param array ...$args The function's argument.
427
     * @return mixed Returns the result of the function
428
     * @throws RuntimeException Throws an exception when the function isn't found.
429
     */
430 3
    public function call($name, ...$args) {
431 3
        if (!isset($this->functions[$name])) {
432 1
            throw new RuntimeException("Call to undefined function $name.", 500);
433
        } else {
434 2
            return $this->functions[$name](...$args);
435
        }
436
    }
437
438
    /**
439
     * Render a variable appropriately for CSS.
440
     *
441
     * This is a convenience runtime function.
442
     *
443
     * @param string|array $expr A CSS class, an array of CSS classes, or an associative array where the keys are class
444
     * names and the values are truthy conditions to include the class (or not).
445
     * @return string Returns a space-delimited CSS class string.
446
     */
447 6
    public function attributeClass($expr) {
448 6
        if (is_array($expr)) {
449 3
            $classes = [];
450 3
            foreach ($expr as $i => $val) {
451 3
                if (is_array($val)) {
452 1
                    $classes[] = $this->attributeClass($val);
453 3
                } elseif (is_int($i)) {
454 1
                    $classes[] = $val;
455 2
                } elseif (!empty($val)) {
456 3
                    $classes[] = $i;
457
                }
458
            }
459 3
            return implode(' ', $classes);
460
        } else {
461 3
            return (string)$expr;
462
        }
463
    }
464
465
    /**
466
     * Format a data.
467
     *
468
     * @param mixed $date The date to format. This can be a string data, a timestamp or an instance of **DateTimeInterface**.
469
     * @param string $format The format of the date.
470
     * @return string Returns the formatted data.
471
     * @see date_format()
472
     */
473 1
    public function formatDate($date, $format = 'c') {
474 1
        if (is_string($date)) {
475
            try {
476 1
                $date = new \DateTimeImmutable($date);
477
            } catch (\Exception $ex) {
478 1
                return '#error#';
479
            }
480
        } elseif (empty($date)) {
481
            return '';
482
        } elseif (is_int($date)) {
483
            try {
484
                $date = new \DateTimeImmutable('@'.$date);
485
            } catch (\Exception $ex) {
486
                return '#error#';
487
            }
488
        } elseif (!$date instanceof \DateTimeInterface) {
489
            return '#error#';
490
        }
491
492 1
        return $date->format($format);
493
    }
494
495
    /**
496
     * Get a single item from the meta array.
497
     *
498
     * @param string $name The key to get from.
499
     * @param mixed $default The default value if no item at the key exists.
500
     * @return mixed Returns the meta value.
501
     */
502
    public function getMeta($name, $default = null) {
503
        return isset($this->meta[$name]) ? $this->meta[$name] : $default;
504
    }
505
506
    /**
507
     * Set a single item to the meta array.
508
     *
509
     * @param string $name The key to set.
510
     * @param mixed $value The new value.
511
     * @return $this
512
     */
513 1
    public function setMeta($name, $value) {
514 1
        $this->meta[$name] = $value;
515 1
        return $this;
516
    }
517
518
    /**
519
     * Get the template loader.
520
     *
521
     * The template loader translates component names into template contents.
522
     *
523
     * @return TemplateLoaderInterface Returns the template loader.
524
     */
525 1
    public function getTemplateLoader() {
526 1
        return $this->templateLoader;
527
    }
528
529
    /**
530
     * Set the template loader.
531
     *
532
     * The template loader translates component names into template contents.
533
     *
534
     * @param TemplateLoaderInterface $templateLoader The new template loader.
535
     * @return $this
536
     */
537
    public function setTemplateLoader($templateLoader) {
538
        $this->templateLoader = $templateLoader;
539
        return $this;
540
    }
541
542
    /**
543
     * Get the entire meta array.
544
     *
545
     * @return array Returns the meta.
546
     */
547
    public function getMetaArray() {
548
        return $this->meta;
549
    }
550
551
    /**
552
     * Set the entire meta array.
553
     *
554
     * @param array $meta The new meta array.
555
     * @return $this
556
     */
557
    public function setMetaArray(array $meta) {
558
        $this->meta = $meta;
559
        return $this;
560
    }
561
562
    /**
563
     * Return a dynamic attribute.
564
     *
565
     * The attribute renders differently depending on the value.
566
     *
567
     * - If the value is **true** then it will render as an HTML5 boolean attribute.
568
     * - If the value is **false** or **null** then the attribute will not render.
569
     * - Other values render as attribute values.
570
     * - Attributes that start with **aria-** render **true** and **false** as values.
571
     *
572
     * @param string $name The name of the attribute.
573
     * @param mixed $value The value of the attribute.
574
     * @return string Returns the attribute definition or an empty string.
575
     */
576 15
    protected function attribute($name, $value) {
577 15
        if (substr($name, 0, 5) === 'aria-' && is_bool($value)) {
578 2
            $value = $value ? 'true' : 'false';
579
        }
580
581 15
        if ($value === true) {
582 1
            return ' '.$name;
583 14
        } elseif (!in_array($value, [null, false], true)) {
584 11
            return " $name=\"".htmlspecialchars($value).'"';
585
        }
586 4
        return '';
587
    }
588
589
    /**
590
     * Escape a value for echoing to HTML with a bit of non-scalar checking.
591
     *
592
     * @param mixed $val The value to escape.
593
     * @return string The escaped value.
594
     */
595 24
    protected function escape($val = null) {
596 24
        if (is_array($val)) {
597 1
            return '[array]';
598 24
        } elseif ($val instanceof \DateTimeInterface) {
599 1
            return htmlspecialchars($val->format(\DateTime::RFC3339));
600 24
        } elseif (is_object($val) && !method_exists($val, '__toString')) {
601 1
            return '{object}';
602
        } else {
603 24
            return htmlspecialchars($val);
604
        }
605
    }
606
}
607