Completed
Push — master ( eead8a...86fe62 )
by Vladimir
02:20
created

FrontMatterObject::getPermalink()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 7.0061

Importance

Changes 0
Metric Value
cc 7
eloc 18
nc 10
nop 0
dl 0
loc 33
ccs 19
cts 20
cp 0.95
crap 7.0061
rs 6.7272
c 0
b 0
f 0
1
<?php
2
3
namespace allejo\stakx\Object;
4
5
use allejo\stakx\FrontMatter\FrontMatterParser;
6
use allejo\stakx\FrontMatter\YamlVariableUndefinedException;
7
use allejo\stakx\System\Filesystem;
8
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
9
use Symfony\Component\Filesystem\Exception\IOException;
10
use Symfony\Component\Finder\SplFileInfo;
11
use Symfony\Component\Yaml\Yaml;
12
13
abstract class FrontMatterObject implements Jailable
14
{
15
    protected static $whiteListFunctions = array(
16
        'getPermalink', 'getRedirects', 'getTargetFile', 'getName', 'getFilePath', 'getRelativeFilePath', 'getContent'
17
    );
18
19
    /**
20
     * An array to keep track of collection or data dependencies used inside of a Twig template
21
     *
22
     * $dataDependencies['collections'] = array()
23
     * $dataDependencies['data'] = array()
24
     *
25
     * @var array
26
     */
27
    protected $dataDependencies;
28
29
    /**
30
     * FrontMatter values that can be injected or set after the file has been parsed. Values in this array will take
31
     * precedence over values in $frontMatter
32
     *
33
     * @var array
34
     */
35
    protected $writableFrontMatter;
36
37
    /**
38
     * A list of Front Matter values that should not be returned directly from the $frontMatter array. Values listed
39
     * here have dedicated functions that handle those Front Matter values and the respective functions should be called
40
     * instead.
41
     *
42
     * @var string[]
43
     */
44
    protected $frontMatterBlacklist;
45
46
    /**
47
     * Set to true if the front matter has already been evaluated with variable interpolation
48
     *
49
     * @var bool
50
     */
51
    protected $frontMatterEvaluated;
52
53
    /**
54
     * @var FrontMatterParser
55
     */
56
    protected $frontMatterParser;
57
58
    /**
59
     * An array containing the Yaml of the file
60
     *
61
     * @var array
62
     */
63
    protected $frontMatter;
64
65
    /**
66
     * Set to true if the body has already been parsed as markdown or any other format
67
     *
68
     * @var bool
69
     */
70
    protected $bodyContentEvaluated;
71
72
    /**
73
     * Only the body of the file, i.e. the content
74
     *
75
     * @var string
76
     */
77
    protected $bodyContent;
78
79
    /**
80
     * The extension of the file
81
     *
82
     * @var string
83
     */
84
    protected $extension;
85
86
    /**
87
     * The original file path to the ContentItem
88
     *
89
     * @var SplFileInfo
90
     */
91
    protected $filePath;
92
93
    /**
94
     * The permalink for this object
95
     *
96
     * @var string
97
     */
98
    protected $permalink;
99
100
    /**
101
     * A filesystem object
102
     *
103
     * @var Filesystem
104
     */
105
    protected $fs;
106
107
    /**
108
     * A list URLs that will redirect to this object
109
     *
110
     * @var string[]
111
     */
112
    private $redirects;
113
114
    /**
115
     * The number of lines that Twig template errors should offset
116
     *
117
     * @var int
118
     */
119
    private $lineOffset;
120
121
    /**
122
     * ContentItem constructor.
123
     *
124
     * @param string $filePath The path to the file that will be parsed into a ContentItem
125
     *
126
     * @throws FileNotFoundException The given file path does not exist
127
     * @throws IOException           The file was not a valid ContentItem. This would meam there was no front matter or
128
     *                               no body
129
     */
130 37
    public function __construct ($filePath)
131
    {
132 37
        $this->frontMatterBlacklist = array('permalink', 'redirects');
133 37
        $this->writableFrontMatter = array();
134
135 37
        $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...
136 37
        $this->fs        = new Filesystem();
137
138 37
        if (!$this->fs->exists($filePath))
139 37
        {
140 1
            throw new FileNotFoundException("The following file could not be found: ${filePath}");
141
        }
142
143
        $this->extension = strtolower($this->fs->getExtension($filePath));
144
145
        $this->refreshFileContent();
146
    }
147
148
    /**
149
     * The magic getter returns values from the front matter in order to make these values accessible to Twig templates
150
     * in a simple fashion
151
     *
152
     * @param  string $name The key in the front matter
153
     *
154
     * @return mixed|null
155
     */
156
    public function __get ($name)
157
    {
158 4
        if (isset($this->writableFrontMatter[$name]))
159 4
        {
160 1
            return $this->writableFrontMatter[$name];
161
        }
162
163 3
        return (isset($this->frontMatter[$name]) ? $this->frontMatter[$name] : null);
164
    }
165
166
    /**
167
     * The magic getter returns true if the value exists in the Front Matter. This is used in conjunction with the __get
168
     * function
169
     *
170
     * @param  string $name The name of the Front Matter value being looked for
171
     *
172
     * @return bool
173
     */
174
    public function __isset ($name)
175
    {
176 1
        return $this->isMagicGet($name);
177
    }
178
179
    /**
180
     * Check if a specific value is defined in the Front Matter
181
     *
182
     * @param  string $name
183
     *
184
     * @return bool
185
     */
186
    public function isMagicGet ($name)
187
    {
188
        return (
189 2
            !in_array($name, $this->frontMatterBlacklist)) &&
190 2
            (isset($this->frontMatter[$name]) || isset($this->writableFrontMatter[$name])
191 2
        );
192
    }
193
194
    /**
195
     * Return the body of the Content Item
196
     *
197
     * @return string
198
     */
199
    abstract public function getContent ();
200
201
    /**
202
     * @param array|null $variables An array of YAML variables to use in evaluating the `$permalink` value
203
     */
204
    final public function evaluateFrontMatter ($variables = null)
205
    {
206 2
        if (!is_null($variables))
207 2
        {
208 2
            $this->frontMatter = array_merge($this->frontMatter, $variables);
209 2
            $this->handleSpecialFrontMatter();
210 2
            $this->evaluateYaml($this->frontMatter);
211 2
        }
212 2
    }
213
214
    /**
215
     * Get the Front Matter of a ContentItem as an array
216
     *
217
     * @param  bool $evaluateYaml When set to true, the YAML will be evaluated for variables
218
     *
219
     * @return array
220
     */
221
    final public function getFrontMatter ($evaluateYaml = true)
222
    {
223 8
        if (is_null($this->frontMatter))
224 8
        {
225 1
            $this->frontMatter = array();
226 1
        }
227 7
        else if (!$this->frontMatterEvaluated && $evaluateYaml)
228 7
        {
229 7
            $this->evaluateYaml($this->frontMatter);
230 6
            $this->frontMatterEvaluated = true;
231 6
        }
232
233 7
        return $this->frontMatter;
234
    }
235
236
    /**
237
     * Get the permalink of this Content Item
238
     *
239
     * @return string
240
     * @throws \Exception
241
     */
242
    final public function getPermalink ()
243
    {
244 15
        if (!is_null($this->permalink))
245 15
        {
246 2
            return $this->permalink;
247
        }
248
249 14
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
250 14
        {
251
            throw new \Exception('The permalink for this item has not been set');
252
        }
253
254 14
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
255 14
            $this->frontMatter['permalink'] : $this->getPathPermalink();
256
257 14
        if (is_array($permalink))
258 14
        {
259 3
            $this->permalink = $permalink[0];
260 3
            array_shift($permalink);
261 3
            $this->redirects = $permalink;
262 3
        }
263
        else
264
        {
265 11
            $this->permalink = $permalink;
266 11
            $this->redirects = array();
267
        }
268
269 14
        $this->permalink = $this->sanitizePermalink($this->permalink);
270 14
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
271 14
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
272
273 14
        return $this->permalink;
274
    }
275
276
    /**
277
     * Get an array of URLs that will redirect to
278
     *
279
     * @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...
280
     */
281
    final public function getRedirects ()
282
    {
283 1
        if (is_null($this->redirects))
284 1
        {
285
            $this->getPermalink();
286
        }
287
288 1
        return $this->redirects;
289
    }
290
291
    /**
292
     * Get the destination of where this Content Item would be written to when the website is compiled
293
     *
294
     * @return string
295
     */
296
    final public function getTargetFile ()
297
    {
298 7
        $permalink = $this->getPermalink();
299 7
        $extension = $this->fs->getExtension($permalink);
300 7
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
301
302 7
        if (empty($extension))
303 7
        {
304 3
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
305 3
        }
306
307 7
        return ltrim($permalink, DIRECTORY_SEPARATOR);
308
    }
309
310
    /**
311
     * Get the name of the item, which is just the file name without the extension
312
     *
313
     * @return string
314
     */
315
    final public function getName ()
316
    {
317 4
        return $this->fs->getBaseName($this->filePath);
318
    }
319
320
    /**
321
     * Get the original file path
322
     *
323
     * @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...
324
     */
325
    final public function getFilePath ()
326
    {
327 1
        return $this->filePath;
328
    }
329
330
    /**
331
     * Get the relative path to this file relative to the root of the Stakx website
332
     *
333
     * @return string
334
     */
335
    final public function getRelativeFilePath ()
336
    {
337 8
        if ($this->filePath instanceof SplFileInfo)
338 8
        {
339 5
            return $this->filePath->getRelativePathname();
340
        }
341
342
        // 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...
343 3
        return $this->fs->getRelativePath($this->filePath);
344
    }
345
346
    /**
347
     * The number of lines that are taken up by FrontMatter and white space
348
     *
349
     * @return int
350
     */
351
    final public function getLineOffset ()
352
    {
353
        return $this->lineOffset;
354
    }
355
356
    /**
357
     * Returns true when the evaluated Front Matter has expanded values embeded
358
     *
359
     * @return bool
360
     */
361
    final public function hasExpandedFrontMatter ()
362
    {
363 1
        return (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion());
364
    }
365
366
    /**
367
     * Read the file, and parse its contents
368
     */
369
    final public function refreshFileContent ()
370
    {
371 36
        $rawFileContents = file_get_contents($this->filePath);
372
373
        /** @var string[] $frontMatter */
374 36
        $frontMatter = array();
375 36
        preg_match('/---(.*?)---(\n(?:[\s|\n]+)?)(.*)/s', $rawFileContents, $frontMatter);
376
377 36
        if (count($frontMatter) != 4)
378 36
        {
379 2
            throw new IOException(sprintf("'%s' is not a valid ContentItem",
380 2
                    $this->fs->getFileName($this->filePath))
381 2
            );
382
        }
383
384 34
        if (empty(trim($frontMatter[3])))
385 34
        {
386
            throw new IOException(sprintf('A ContentItem (%s) must have a body to render',
387
                    $this->fs->getFileName($this->filePath))
388
            );
389
        }
390
391 34
        $this->lineOffset  = substr_count($frontMatter[1], "\n") + substr_count($frontMatter[2], "\n");
392 34
        $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...
393 33
        $this->bodyContent = $frontMatter[3];
394
395 33
        $this->frontMatterEvaluated = false;
396 33
        $this->bodyContentEvaluated = false;
397 33
        $this->permalink = null;
398
399 33
        $this->handleSpecialFrontMatter();
400 33
        $this->findTwigDataDependencies('collections');
401 33
        $this->findTwigDataDependencies('data');
402 33
    }
403
404
    /**
405
     * Check whether this object has a reference to a collection or data item
406
     *
407
     * @param  string $namespace 'collections' or 'data'
408
     * @param  string $needle
409
     *
410
     * @return bool
411
     */
412
    final public function hasTwigDependency ($namespace, $needle)
413
    {
414
        return (in_array($needle, $this->dataDependencies[$namespace]));
415
    }
416
417
    /**
418
     * Append a custom FrontMatter value
419
     *
420
     * @param array $frontMatter
421
     */
422
    final public function appendFrontMatter (array $frontMatter)
423
    {
424
        foreach ($frontMatter as $key => $value)
425
        {
426
            $this->writableFrontMatter[$key] = $value;
427
        }
428
    }
429
430
    /**
431
     * Delete a custom FrontMatter value
432
     *
433
     * This will not delete a FrontMatter value parsed from the file
434
     *
435
     * @param string $key
436
     */
437
    final public function deleteFrontMatter ($key)
438
    {
439
        if (!isset($this->writableFrontMatter[$key])) { return; }
440
441
        unset($this->writableFrontMatter[$key]);
442
    }
443
444
    /**
445
     * Set custom FrontMatter values
446
     *
447
     * These custom values are temporary and will take precedence over Front Matter evaluated from the file but is only
448
     * available to Twig templates
449
     *
450
     * @param array $frontMatter
451
     */
452
    final public function setFrontMatter (array $frontMatter)
453
    {
454 1
        if (!is_array($frontMatter))
455 1
        {
456
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
457
        }
458
459 1
        $this->writableFrontMatter = $frontMatter;
460 1
    }
461
462
    /**
463
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
464
     *
465
     * @param  array $yaml An array of data containing FrontMatter variables
466
     *
467
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
468
     */
469
    final protected function evaluateYaml (&$yaml)
470
    {
471 9
        $this->frontMatterParser = new FrontMatterParser($yaml);
472 8
    }
473
474
    /**
475
     * Handle special front matter values that need special treatment or have special meaning to a Content Item
476
     */
477
    private function handleSpecialFrontMatter ()
478
    {
479 33
        if (isset($this->frontMatter['date']))
480 33
        {
481
            try
482
            {
483
                // Coming from a string variable
484 3
                $itemDate = new \DateTime($this->frontMatter['date']);
485
            }
486 3
            catch (\Exception $e)
487
            {
488
                // YAML has parsed them to Epoch time
489 1
                $itemDate = \DateTime::createFromFormat('U', $this->frontMatter['date']);
490
            }
491
492 3
            if (!$itemDate === false)
493 3
            {
494
                // Localize dates in FrontMatter based on the timezone set in the PHP configuration
495 2
                $timezone = new \DateTimeZone(date_default_timezone_get());
496 2
                $localizedDate = new \DateTime($itemDate->format('Y-m-d h:i:s'), $timezone);
497
498 2
                $this->frontMatter['date']  = $localizedDate->format('U');
499 2
                $this->frontMatter['year']  = $localizedDate->format('Y');
500 2
                $this->frontMatter['month'] = $localizedDate->format('m');
501 2
                $this->frontMatter['day']   = $localizedDate->format('d');
502 2
            }
503 3
        }
504 33
    }
505
506
    /**
507
     * Get all of the references to either DataItems or ContentItems inside of given string
508
     *
509
     * @param string $filter 'collections' or 'data'
510
     */
511
    private function findTwigDataDependencies ($filter)
512
    {
513 33
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
514 33
        $results = array();
515
516 33
        preg_match_all($regex, $this->bodyContent, $results);
517
518 33
        $this->dataDependencies[$filter] = array_unique($results[1]);
519 33
    }
520
521
    /**
522
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
523
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
524
     *
525
     * @return string
526
     */
527
    private function getPathPermalink ()
528
    {
529
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
530 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->getRelativeFilePath());
531 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
532
533
        // Handle vfs:// paths by replacing their forward slashes with the OS appropriate directory separator
534 3
        if (DIRECTORY_SEPARATOR !== '/')
535 3
        {
536
            $cleanPath = str_replace('/', DIRECTORY_SEPARATOR, $cleanPath);
537
        }
538
539
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
540 3
        $folders = explode(DIRECTORY_SEPARATOR, $cleanPath);
541
542 3
        if (substr($folders[0], 0, 1) === '_')
543 3
        {
544 1
            array_shift($folders);
545 1
        }
546
547 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
548
549 3
        return $cleanPath;
550
    }
551
552
    /**
553
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens
554
     *
555
     * @param  string $permalink A permalink
556
     *
557
     * @return string $permalink The sanitized permalink
558
     */
559
    private function sanitizePermalink ($permalink)
560
    {
561
        // Remove multiple '/' together
562 14
        $permalink = preg_replace('/\/+/', '/', $permalink);
563
564
        // Replace all spaces with hyphens
565 14
        $permalink = str_replace(' ', '-', $permalink);
566
567
        // Remove all disallowed characters
568 14
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
569
570
        // Handle unnecessary extensions
571 14
        $extensionsToStrip = array('twig');
572
573 14
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
574 14
        {
575 4
            $permalink = $this->fs->removeExtension($permalink);
576 4
        }
577
578
        // Remove any special characters before a sane value
579 14
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
580
581
        // Convert permalinks to lower case
582 14
        $permalink = mb_strtolower($permalink, 'UTF-8');
583
584 14
        return $permalink;
585
    }
586
}