Completed
Push — master ( 045ed5...67de6b )
by Vladimir
02:06
created

FrontMatterObject::getPermalink()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 32
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7.0071

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 10
nop 0
dl 0
loc 32
ccs 18
cts 19
cp 0.9474
crap 7.0071
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 = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
271
272 14
        return $this->permalink;
273
    }
274
275
    /**
276
     * Get an array of URLs that will redirect to
277
     *
278
     * @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...
279
     */
280
    final public function getRedirects ()
281
    {
282 1
        if (is_null($this->redirects))
283 1
        {
284
            $this->getPermalink();
285
        }
286
287 1
        return $this->redirects;
288
    }
289
290
    /**
291
     * Get the destination of where this Content Item would be written to when the website is compiled
292
     *
293
     * @return string
294
     */
295
    final public function getTargetFile ()
296
    {
297 7
        $permalink = $this->getPermalink();
298 7
        $extension = $this->fs->getExtension($permalink);
299 7
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
300
301 7
        if (empty($extension))
302 7
        {
303 3
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
304 3
        }
305
306 7
        return ltrim($permalink, DIRECTORY_SEPARATOR);
307
    }
308
309
    /**
310
     * Get the name of the item, which is just the file name without the extension
311
     *
312
     * @return string
313
     */
314
    final public function getName ()
315
    {
316 4
        return $this->fs->getBaseName($this->filePath);
317
    }
318
319
    /**
320
     * Get the original file path
321
     *
322
     * @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...
323
     */
324
    final public function getFilePath ()
325
    {
326 1
        return $this->filePath;
327
    }
328
329
    /**
330
     * Get the relative path to this file relative to the root of the Stakx website
331
     *
332
     * @return string
333
     */
334
    final public function getRelativeFilePath ()
335
    {
336 8
        if ($this->filePath instanceof SplFileInfo)
337 8
        {
338 5
            return $this->filePath->getRelativePathname();
339
        }
340
341
        // 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...
342 3
        return $this->fs->getRelativePath($this->filePath);
343
    }
344
345
    /**
346
     * The number of lines that are taken up by FrontMatter and white space
347
     *
348
     * @return int
349
     */
350
    final public function getLineOffset ()
351
    {
352
        return $this->lineOffset;
353
    }
354
355
    /**
356
     * Returns true when the evaluated Front Matter has expanded values embeded
357
     *
358
     * @return bool
359
     */
360
    final public function hasExpandedFrontMatter ()
361
    {
362 1
        return (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion());
363
    }
364
365
    /**
366
     * Read the file, and parse its contents
367
     */
368
    final public function refreshFileContent ()
369
    {
370 36
        $rawFileContents = file_get_contents($this->filePath);
371
372
        /** @var string[] $frontMatter */
373 36
        $frontMatter = array();
374 36
        preg_match('/---(.*?)---(\n(?:[\s|\n]+)?)(.*)/s', $rawFileContents, $frontMatter);
375
376 36
        if (count($frontMatter) != 4)
377 36
        {
378 2
            throw new IOException(sprintf("'%s' is not a valid ContentItem",
379 2
                    $this->fs->getFileName($this->filePath))
380 2
            );
381
        }
382
383 34
        if (empty(trim($frontMatter[3])))
384 34
        {
385
            throw new IOException(sprintf('A ContentItem (%s) must have a body to render',
386
                    $this->fs->getFileName($this->filePath))
387
            );
388
        }
389
390 34
        $this->lineOffset  = substr_count($frontMatter[1], "\n") + substr_count($frontMatter[2], "\n");
391 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...
392 33
        $this->bodyContent = $frontMatter[3];
393
394 33
        $this->frontMatterEvaluated = false;
395 33
        $this->bodyContentEvaluated = false;
396 33
        $this->permalink = null;
397
398 33
        $this->handleSpecialFrontMatter();
399 33
        $this->findTwigDataDependencies('collections');
400 33
        $this->findTwigDataDependencies('data');
401 33
    }
402
403
    /**
404
     * Check whether this object has a reference to a collection or data item
405
     *
406
     * @param  string $namespace 'collections' or 'data'
407
     * @param  string $needle
408
     *
409
     * @return bool
410
     */
411
    final public function hasTwigDependency ($namespace, $needle)
412
    {
413
        return (in_array($needle, $this->dataDependencies[$namespace]));
414
    }
415
416
    /**
417
     * Append a custom FrontMatter value
418
     *
419
     * @param array $frontMatter
420
     */
421
    final public function appendFrontMatter (array $frontMatter)
422
    {
423
        foreach ($frontMatter as $key => $value)
424
        {
425
            $this->writableFrontMatter[$key] = $value;
426
        }
427
    }
428
429
    /**
430
     * Delete a custom FrontMatter value
431
     *
432
     * This will not delete a FrontMatter value parsed from the file
433
     *
434
     * @param string $key
435
     */
436
    final public function deleteFrontMatter ($key)
437
    {
438
        if (!isset($this->writableFrontMatter[$key])) { return; }
439
440
        unset($this->writableFrontMatter[$key]);
441
    }
442
443
    /**
444
     * Set custom FrontMatter values
445
     *
446
     * These custom values are temporary and will take precedence over Front Matter evaluated from the file but is only
447
     * available to Twig templates
448
     *
449
     * @param array $frontMatter
450
     */
451
    final public function setFrontMatter (array $frontMatter)
452
    {
453 1
        if (!is_array($frontMatter))
454 1
        {
455
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
456
        }
457
458 1
        $this->writableFrontMatter = $frontMatter;
459 1
    }
460
461
    /**
462
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
463
     *
464
     * @param  array $yaml An array of data containing FrontMatter variables
465
     *
466
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
467
     */
468
    final protected function evaluateYaml (&$yaml)
469
    {
470 9
        $this->frontMatterParser = new FrontMatterParser($yaml);
471 8
    }
472
473
    /**
474
     * Handle special front matter values that need special treatment or have special meaning to a Content Item
475
     */
476
    private function handleSpecialFrontMatter ()
477
    {
478 33
        if (isset($this->frontMatter['date']))
479 33
        {
480
            try
481
            {
482
                // Coming from a string variable
483 3
                $itemDate = new \DateTime($this->frontMatter['date']);
484
            }
485 3
            catch (\Exception $e)
486
            {
487
                // YAML has parsed them to Epoch time
488 1
                $itemDate = \DateTime::createFromFormat('U', $this->frontMatter['date']);
489
            }
490
491 3
            if (!$itemDate === false)
492 3
            {
493
                // Localize dates in FrontMatter based on the timezone set in the PHP configuration
494 2
                $timezone = new \DateTimeZone(date_default_timezone_get());
495 2
                $localizedDate = new \DateTime($itemDate->format('Y-m-d h:i:s'), $timezone);
496
497 2
                $this->frontMatter['date']  = $localizedDate->format('U');
498 2
                $this->frontMatter['year']  = $localizedDate->format('Y');
499 2
                $this->frontMatter['month'] = $localizedDate->format('m');
500 2
                $this->frontMatter['day']   = $localizedDate->format('d');
501 2
            }
502 3
        }
503 33
    }
504
505
    /**
506
     * Get all of the references to either DataItems or ContentItems inside of given string
507
     *
508
     * @param string $filter 'collections' or 'data'
509
     */
510
    private function findTwigDataDependencies ($filter)
511
    {
512 33
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
513 33
        $results = array();
514
515 33
        preg_match_all($regex, $this->bodyContent, $results);
516
517 33
        $this->dataDependencies[$filter] = array_unique($results[1]);
518 33
    }
519
520
    /**
521
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
522
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
523
     *
524
     * @return string
525
     */
526
    private function getPathPermalink ()
527
    {
528
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
529 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->getRelativeFilePath());
530 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
531
532
        // Handle vfs:// paths by replacing their forward slashes with the OS appropriate directory separator
533 3
        if (DIRECTORY_SEPARATOR !== '/')
534 3
        {
535
            $cleanPath = str_replace('/', DIRECTORY_SEPARATOR, $cleanPath);
536
        }
537
538
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
539 3
        $folders = explode(DIRECTORY_SEPARATOR, $cleanPath);
540
541 3
        if (substr($folders[0], 0, 1) === '_')
542 3
        {
543 1
            array_shift($folders);
544 1
        }
545
546 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
547
548 3
        return $cleanPath;
549
    }
550
551
    /**
552
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens
553
     *
554
     * @param  string $permalink A permalink
555
     *
556
     * @return string $permalink The sanitized permalink
557
     */
558
    private function sanitizePermalink ($permalink)
559
    {
560
        // Remove multiple '/' together
561 14
        $permalink = preg_replace('/\/+/', '/', $permalink);
562
563
        // Replace all spaces with hyphens
564 14
        $permalink = str_replace(' ', '-', $permalink);
565
566
        // Remove all disallowed characters
567 14
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
568
569
        // Handle unnecessary extensions
570 14
        $extensionsToStrip = array('twig');
571
572 14
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
573 14
        {
574 4
            $permalink = $this->fs->removeExtension($permalink);
575 4
        }
576
577
        // Remove a special './' combination from the beginning of a path
578 14
        if (substr($permalink, 0, 2) === '.' . DIRECTORY_SEPARATOR)
579 14
        {
580 2
            $permalink = substr($permalink, 2);
581 2
        }
582
583
        // Convert permalinks to lower case
584 14
        $permalink = mb_strtolower($permalink, 'UTF-8');
585
586 14
        return $permalink;
587
    }
588
}