Completed
Push — master ( e4bfce...b9fe45 )
by Vladimir
02:19
created

FrontMatterObject::sanitizePermalink()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 27
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2

Importance

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