Completed
Push — master ( 86fe62...e75985 )
by Vladimir
02:25
created

FrontMatterObject::hasExpandedFrontMatter()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 2
nc 2
nop 0
dl 0
loc 4
ccs 1
cts 1
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace allejo\stakx\Object;
4
5
use allejo\stakx\Exception\InvalidSyntaxException;
6
use allejo\stakx\FrontMatter\FrontMatterParser;
7
use allejo\stakx\FrontMatter\YamlVariableUndefinedException;
8
use allejo\stakx\System\Filesystem;
9
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
10
use Symfony\Component\Filesystem\Exception\IOException;
11
use Symfony\Component\Finder\SplFileInfo;
12
use Symfony\Component\Yaml\Yaml;
13
14
abstract class FrontMatterObject implements Jailable
15
{
16
    protected static $whiteListFunctions = array(
17
        'getPermalink', 'getRedirects', 'getTargetFile', 'getName', 'getFilePath', 'getRelativeFilePath', 'getContent'
18
    );
19
20
    /**
21
     * An array to keep track of collection or data dependencies used inside of a Twig template
22
     *
23
     * $dataDependencies['collections'] = array()
24
     * $dataDependencies['data'] = array()
25
     *
26
     * @var array
27
     */
28
    protected $dataDependencies;
29
30
    /**
31
     * FrontMatter values that can be injected or set after the file has been parsed. Values in this array will take
32
     * precedence over values in $frontMatter
33
     *
34
     * @var array
35
     */
36
    protected $writableFrontMatter;
37
38
    /**
39
     * A list of Front Matter values that should not be returned directly from the $frontMatter array. Values listed
40
     * here have dedicated functions that handle those Front Matter values and the respective functions should be called
41
     * instead.
42
     *
43
     * @var string[]
44
     */
45
    protected $frontMatterBlacklist;
46
47
    /**
48
     * Set to true if the front matter has already been evaluated with variable interpolation
49
     *
50
     * @var bool
51
     */
52
    protected $frontMatterEvaluated;
53
54
    /**
55
     * @var FrontMatterParser
56
     */
57
    protected $frontMatterParser;
58
59
    /**
60
     * An array containing the Yaml of the file
61
     *
62
     * @var array
63
     */
64
    protected $frontMatter;
65
66
    /**
67
     * Set to true if the body has already been parsed as markdown or any other format
68
     *
69
     * @var bool
70
     */
71
    protected $bodyContentEvaluated;
72
73
    /**
74
     * Only the body of the file, i.e. the content
75
     *
76
     * @var string
77
     */
78
    protected $bodyContent;
79
80
    /**
81
     * The extension of the file
82
     *
83
     * @var string
84
     */
85
    protected $extension;
86
87
    /**
88
     * The original file path to the ContentItem
89
     *
90
     * @var SplFileInfo
91
     */
92
    protected $filePath;
93
94
    /**
95
     * The permalink for this object
96
     *
97
     * @var string
98
     */
99
    protected $permalink;
100
101
    /**
102
     * A filesystem object
103
     *
104
     * @var Filesystem
105
     */
106
    protected $fs;
107
108
    /**
109
     * A list URLs that will redirect to this object
110
     *
111
     * @var string[]
112
     */
113
    private $redirects;
114
115
    /**
116
     * The number of lines that Twig template errors should offset
117
     *
118
     * @var int
119
     */
120
    private $lineOffset;
121
122
    /**
123
     * ContentItem constructor.
124
     *
125
     * @param string $filePath The path to the file that will be parsed into a ContentItem
126
     *
127
     * @throws FileNotFoundException The given file path does not exist
128
     * @throws IOException           The file was not a valid ContentItem. This would meam there was no front matter or
129
     *                               no body
130
     */
131 39
    public function __construct ($filePath)
132
    {
133 39
        $this->frontMatterBlacklist = array('permalink', 'redirects');
134 39
        $this->writableFrontMatter = array();
135
136 39
        $this->filePath  = $filePath;
0 ignored issues
show
Documentation Bug introduced by
It seems like $filePath of type string is incompatible with the declared type object<Symfony\Component\Finder\SplFileInfo> of property $filePath.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
137 39
        $this->fs        = new Filesystem();
138
139 39
        if (!$this->fs->exists($filePath))
140 39
        {
141 1
            throw new FileNotFoundException("The following file could not be found: ${filePath}");
142
        }
143
144
        $this->extension = strtolower($this->fs->getExtension($filePath));
145
146
        $this->refreshFileContent();
147
    }
148
149
    /**
150
     * The magic getter returns values from the front matter in order to make these values accessible to Twig templates
151
     * in a simple fashion
152
     *
153
     * @param  string $name The key in the front matter
154
     *
155
     * @return mixed|null
156
     */
157
    public function __get ($name)
158
    {
159 4
        if (isset($this->writableFrontMatter[$name]))
160 4
        {
161 1
            return $this->writableFrontMatter[$name];
162
        }
163
164 3
        return (isset($this->frontMatter[$name]) ? $this->frontMatter[$name] : null);
165
    }
166
167
    /**
168
     * The magic getter returns true if the value exists in the Front Matter. This is used in conjunction with the __get
169
     * function
170
     *
171
     * @param  string $name The name of the Front Matter value being looked for
172
     *
173
     * @return bool
174
     */
175
    public function __isset ($name)
176
    {
177 1
        return $this->isMagicGet($name);
178
    }
179
180
    /**
181
     * Check if a specific value is defined in the Front Matter
182
     *
183
     * @param  string $name
184
     *
185
     * @return bool
186
     */
187
    public function isMagicGet ($name)
188
    {
189
        return (
190 2
            !in_array($name, $this->frontMatterBlacklist)) &&
191 2
            (isset($this->frontMatter[$name]) || isset($this->writableFrontMatter[$name])
192 2
        );
193
    }
194
195
    /**
196
     * Return the body of the Content Item
197
     *
198
     * @return string
199
     */
200
    abstract public function getContent ();
201
202
    /**
203
     * @param array|null $variables An array of YAML variables to use in evaluating the `$permalink` value
204
     */
205
    final public function evaluateFrontMatter ($variables = null)
206
    {
207 2
        if (!is_null($variables))
208 2
        {
209 2
            $this->frontMatter = array_merge($this->frontMatter, $variables);
210 2
            $this->handleSpecialFrontMatter();
211 2
            $this->evaluateYaml($this->frontMatter);
212 2
        }
213 2
    }
214
215
    /**
216
     * Get the Front Matter of a ContentItem as an array
217
     *
218
     * @param  bool $evaluateYaml When set to true, the YAML will be evaluated for variables
219
     *
220
     * @return array
221
     */
222
    final public function getFrontMatter ($evaluateYaml = true)
223
    {
224 8
        if (is_null($this->frontMatter))
225 8
        {
226 1
            $this->frontMatter = array();
227 1
        }
228 7
        else if (!$this->frontMatterEvaluated && $evaluateYaml)
229 7
        {
230 7
            $this->evaluateYaml($this->frontMatter);
231 6
            $this->frontMatterEvaluated = true;
232 6
        }
233
234 7
        return $this->frontMatter;
235
    }
236
237
    /**
238
     * Get the permalink of this Content Item
239
     *
240
     * @return string
241
     * @throws \Exception
242
     */
243
    final public function getPermalink ()
244
    {
245 15
        if (!is_null($this->permalink))
246 15
        {
247 2
            return $this->permalink;
248
        }
249
250 14
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
251 14
        {
252
            throw new \Exception('The permalink for this item has not been set');
253
        }
254
255 14
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
256 14
            $this->frontMatter['permalink'] : $this->getPathPermalink();
257
258 14
        if (is_array($permalink))
259 14
        {
260 3
            $this->permalink = $permalink[0];
261 3
            array_shift($permalink);
262 3
            $this->redirects = $permalink;
263 3
        }
264
        else
265
        {
266 11
            $this->permalink = $permalink;
267 11
            $this->redirects = array();
268
        }
269
270 14
        $this->permalink = $this->sanitizePermalink($this->permalink);
271 14
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
272 14
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
273
274 14
        return $this->permalink;
275
    }
276
277
    /**
278
     * Get an array of URLs that will redirect to
279
     *
280
     * @return string[]
0 ignored issues
show
Documentation introduced by
Should the return type not be null|string[]?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
281
     */
282
    final public function getRedirects ()
283
    {
284 1
        if (is_null($this->redirects))
285 1
        {
286
            $this->getPermalink();
287
        }
288
289 1
        return $this->redirects;
290
    }
291
292
    /**
293
     * Get the destination of where this Content Item would be written to when the website is compiled
294
     *
295
     * @return string
296
     */
297
    final public function getTargetFile ()
298
    {
299 7
        $permalink = $this->getPermalink();
300 7
        $extension = $this->fs->getExtension($permalink);
301 7
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
302
303 7
        if (empty($extension))
304 7
        {
305 3
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
306 3
        }
307
308 7
        return ltrim($permalink, DIRECTORY_SEPARATOR);
309
    }
310
311
    /**
312
     * Get the name of the item, which is just the file name without the extension
313
     *
314
     * @return string
315
     */
316
    final public function getName ()
317
    {
318 4
        return $this->fs->getBaseName($this->filePath);
319
    }
320
321
    /**
322
     * Get the original file path
323
     *
324
     * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be SplFileInfo?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
325
     */
326
    final public function getFilePath ()
327
    {
328 2
        return $this->filePath;
329
    }
330
331
    /**
332
     * Get the relative path to this file relative to the root of the Stakx website
333
     *
334
     * @return string
335
     */
336
    final public function getRelativeFilePath ()
337
    {
338 11
        if ($this->filePath instanceof SplFileInfo)
339 11
        {
340 5
            return $this->filePath->getRelativePathname();
341
        }
342
343
        // TODO ensure that we always get SplFileInfo objects, even when handling VFS documents
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
344 6
        return $this->fs->getRelativePath($this->filePath);
345
    }
346
347
    /**
348
     * The number of lines that are taken up by FrontMatter and white space
349
     *
350
     * @return int
351
     */
352
    final public function getLineOffset ()
353
    {
354
        return $this->lineOffset;
355
    }
356
357
    /**
358
     * Returns true when the evaluated Front Matter has expanded values embeded
359
     *
360
     * @return bool
361
     */
362
    final public function hasExpandedFrontMatter ()
363
    {
364 1
        return (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion());
365
    }
366
367
    /**
368
     * Read the file, and parse its contents
369
     */
370
    final public function refreshFileContent ()
371
    {
372
        // This function can be called after the initial object was created and the file may have been deleted since the
373
        // creation of the object.
374 38
        if (!$this->fs->exists($this->filePath))
375 38
        {
376 1
            throw new FileNotFoundException(null, 0, null, $this->filePath);
377
        }
378
379 38
        $rawFileContents = file_get_contents($this->filePath);
380
381 38
        $frontMatter = array();
382 38
        preg_match('/---(.*?)---(\n(?:[\s|\n]+)?)(.*)/s', $rawFileContents, $frontMatter);
383
384 38
        if (count($frontMatter) != 4)
385 38
        {
386 2
            throw new InvalidSyntaxException('Invalid FrontMatter file', 0, null, $this->getRelativeFilePath());
387
        }
388
389 36
        if (empty(trim($frontMatter[3])))
390 36
        {
391 1
            throw new InvalidSyntaxException('FrontMatter files must have a body to render', 0, null, $this->getRelativeFilePath());
392
        }
393
394 35
        $this->lineOffset  = substr_count($frontMatter[1], "\n") + substr_count($frontMatter[2], "\n");
395 35
        $this->frontMatter = Yaml::parse($frontMatter[1]);
0 ignored issues
show
Documentation Bug introduced by
It seems like \Symfony\Component\Yaml\...:parse($frontMatter[1]) can also be of type string or object<stdClass>. However, the property $frontMatter is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
396 34
        $this->bodyContent = $frontMatter[3];
397
398 34
        $this->frontMatterEvaluated = false;
399 34
        $this->bodyContentEvaluated = false;
400 34
        $this->permalink = null;
401
402 34
        $this->handleSpecialFrontMatter();
403 34
        $this->findTwigDataDependencies('collections');
404 34
        $this->findTwigDataDependencies('data');
405 34
    }
406
407
    /**
408
     * Check whether this object has a reference to a collection or data item
409
     *
410
     * @param  string $namespace 'collections' or 'data'
411
     * @param  string $needle
412
     *
413
     * @return bool
414
     */
415
    final public function hasTwigDependency ($namespace, $needle)
416
    {
417
        return (in_array($needle, $this->dataDependencies[$namespace]));
418
    }
419
420
    /**
421
     * Append a custom FrontMatter value
422
     *
423
     * @param array $frontMatter
424
     */
425
    final public function appendFrontMatter (array $frontMatter)
426
    {
427
        foreach ($frontMatter as $key => $value)
428
        {
429
            $this->writableFrontMatter[$key] = $value;
430
        }
431
    }
432
433
    /**
434
     * Delete a custom FrontMatter value
435
     *
436
     * This will not delete a FrontMatter value parsed from the file
437
     *
438
     * @param string $key
439
     */
440
    final public function deleteFrontMatter ($key)
441
    {
442
        if (!isset($this->writableFrontMatter[$key])) { return; }
443
444
        unset($this->writableFrontMatter[$key]);
445
    }
446
447
    /**
448
     * Set custom FrontMatter values
449
     *
450
     * These custom values are temporary and will take precedence over Front Matter evaluated from the file but is only
451
     * available to Twig templates
452
     *
453
     * @param array $frontMatter
454
     */
455
    final public function setFrontMatter (array $frontMatter)
456
    {
457 1
        if (!is_array($frontMatter))
458 1
        {
459
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
460
        }
461
462 1
        $this->writableFrontMatter = $frontMatter;
463 1
    }
464
465
    /**
466
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
467
     *
468
     * @param  array $yaml An array of data containing FrontMatter variables
469
     *
470
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
471
     */
472
    final protected function evaluateYaml (&$yaml)
473
    {
474 9
        $this->frontMatterParser = new FrontMatterParser($yaml);
475 8
    }
476
477
    /**
478
     * Handle special front matter values that need special treatment or have special meaning to a Content Item
479
     */
480
    private function handleSpecialFrontMatter ()
481
    {
482 34
        if (isset($this->frontMatter['date']))
483 34
        {
484
            try
485
            {
486
                // Coming from a string variable
487 3
                $itemDate = new \DateTime($this->frontMatter['date']);
488
            }
489 3
            catch (\Exception $e)
490
            {
491
                // YAML has parsed them to Epoch time
492 1
                $itemDate = \DateTime::createFromFormat('U', $this->frontMatter['date']);
493
            }
494
495 3
            if (!$itemDate === false)
496 3
            {
497
                // Localize dates in FrontMatter based on the timezone set in the PHP configuration
498 2
                $timezone = new \DateTimeZone(date_default_timezone_get());
499 2
                $localizedDate = new \DateTime($itemDate->format('Y-m-d h:i:s'), $timezone);
500
501 2
                $this->frontMatter['date']  = $localizedDate->format('U');
502 2
                $this->frontMatter['year']  = $localizedDate->format('Y');
503 2
                $this->frontMatter['month'] = $localizedDate->format('m');
504 2
                $this->frontMatter['day']   = $localizedDate->format('d');
505 2
            }
506 3
        }
507 34
    }
508
509
    /**
510
     * Get all of the references to either DataItems or ContentItems inside of given string
511
     *
512
     * @param string $filter 'collections' or 'data'
513
     */
514
    private function findTwigDataDependencies ($filter)
515
    {
516 34
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
517 34
        $results = array();
518
519 34
        preg_match_all($regex, $this->bodyContent, $results);
520
521 34
        $this->dataDependencies[$filter] = array_unique($results[1]);
522 34
    }
523
524
    /**
525
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
526
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
527
     *
528
     * @return string
529
     */
530
    private function getPathPermalink ()
531
    {
532
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
533 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->getRelativeFilePath());
534 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
535
536
        // Handle vfs:// paths by replacing their forward slashes with the OS appropriate directory separator
537 3
        if (DIRECTORY_SEPARATOR !== '/')
538 3
        {
539
            $cleanPath = str_replace('/', DIRECTORY_SEPARATOR, $cleanPath);
540
        }
541
542
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
543 3
        $folders = explode(DIRECTORY_SEPARATOR, $cleanPath);
544
545 3
        if (substr($folders[0], 0, 1) === '_')
546 3
        {
547 1
            array_shift($folders);
548 1
        }
549
550 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
551
552 3
        return $cleanPath;
553
    }
554
555
    /**
556
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens
557
     *
558
     * @param  string $permalink A permalink
559
     *
560
     * @return string $permalink The sanitized permalink
561
     */
562
    private function sanitizePermalink ($permalink)
563
    {
564
        // Remove multiple '/' together
565 14
        $permalink = preg_replace('/\/+/', '/', $permalink);
566
567
        // Replace all spaces with hyphens
568 14
        $permalink = str_replace(' ', '-', $permalink);
569
570
        // Remove all disallowed characters
571 14
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
572
573
        // Handle unnecessary extensions
574 14
        $extensionsToStrip = array('twig');
575
576 14
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
577 14
        {
578 4
            $permalink = $this->fs->removeExtension($permalink);
579 4
        }
580
581
        // Remove any special characters before a sane value
582 14
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
583
584
        // Convert permalinks to lower case
585 14
        $permalink = mb_strtolower($permalink, 'UTF-8');
586
587 14
        return $permalink;
588
    }
589
}