Completed
Push — master ( 26294a...759c44 )
by Vladimir
02:28
created

FrontMatterObject::getPathPermalink()   B

Complexity

Conditions 3
Paths 4

Size

Total Lines 24
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0327

Importance

Changes 0
Metric Value
cc 3
eloc 10
c 0
b 0
f 0
nc 4
nop 0
dl 0
loc 24
ccs 11
cts 13
cp 0.8462
crap 3.0327
rs 8.9713
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 54
    public function __construct ($filePath)
134
    {
135 54
        $this->frontMatterBlacklist = array('permalink', 'redirects');
136 54
        $this->writableFrontMatter = array();
137
138 54
        $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 54
        $this->fs        = new Filesystem();
140
141 54
        if (!$this->fs->exists($filePath))
142 54
        {
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 4
        if (isset($this->writableFrontMatter[$name]))
162 4
        {
163
            return $this->writableFrontMatter[$name];
164
        }
165
166 4
        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 1
        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 2
        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 4
        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 18
        if ($this->filePath instanceof SplFileInfo)
237 18
        {
238 5
            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 53
        if (!$this->fs->exists($this->filePath))
285 53
        {
286 1
            throw new FileNotFoundException(null, 0, null, $this->filePath);
287
        }
288
289 53
        $rawFileContents = file_get_contents($this->filePath);
290 53
        $fileStructure   = array();
291 53
        preg_match('/---\R(?:(.*)?\R)?---(\R(?:[\s|\R]+)?)(.*)/s', $rawFileContents, $fileStructure);
292
293 53
        if (count($fileStructure) != 4)
294 53
        {
295 9
            throw new InvalidSyntaxException('Invalid FrontMatter file', 0, null, $this->getRelativeFilePath());
296
        }
297
298 44
        if (empty(trim($fileStructure[3])))
299 44
        {
300 1
            throw new InvalidSyntaxException('FrontMatter files must have a body to render', 0, null, $this->getRelativeFilePath());
301
        }
302
303 43
        $this->lineOffset  = substr_count($fileStructure[1], "\n") + substr_count($fileStructure[2], "\n");
304 43
        $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...
305 43
        $this->bodyContent = $fileStructure[3];
306
307 43
        if (!empty($this->frontMatter) && !is_array($this->frontMatter))
308 43
        {
309 1
            throw new ParseException('The evaluated FrontMatter should be an array');
310
        }
311
312 42
        $this->frontMatterEvaluated = false;
313 42
        $this->bodyContentEvaluated = false;
314 42
        $this->permalink = null;
315
316 42
        $this->findTwigDataDependencies('collections');
317 42
        $this->findTwigDataDependencies('data');
318 42
    }
319
320
    /**
321
     * Get all of the references to either DataItems or ContentItems inside of given string
322
     *
323
     * @param string $filter 'collections' or 'data'
324
     */
325
    private function findTwigDataDependencies ($filter)
326
    {
327 42
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
328 42
        $results = array();
329
330 42
        preg_match_all($regex, $this->bodyContent, $results);
331
332 42
        $this->dataDependencies[$filter] = array_unique($results[1]);
333 42
    }
334
335
    //
336
    // Permalink and redirect functionality
337
    //
338
339
    /**
340
     * Get the permalink of this Content Item
341
     *
342
     * @return string
343
     * @throws \Exception
344
     */
345
    final public function getPermalink ()
346
    {
347 15
        if (!is_null($this->permalink))
348 15
        {
349 2
            return $this->permalink;
350
        }
351
352 14
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
353 14
        {
354
            throw new \Exception('The permalink for this item has not been set');
355
        }
356
357 14
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
358 14
            $this->frontMatter['permalink'] : $this->getPathPermalink();
359
360 14
        if (is_array($permalink))
361 14
        {
362 3
            $this->permalink = $permalink[0];
363 3
            array_shift($permalink);
364 3
            $this->redirects = $permalink;
365 3
        }
366
        else
367
        {
368 11
            $this->permalink = $permalink;
369 11
            $this->redirects = array();
370
        }
371
372 14
        $this->permalink = $this->sanitizePermalink($this->permalink);
373 14
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
374 14
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
375
376 14
        return $this->permalink;
377
    }
378
379
    /**
380
     * Get an array of URLs that will redirect to
381
     *
382
     * @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...
383
     */
384
    final public function getRedirects ()
385
    {
386 1
        if (is_null($this->redirects))
387 1
        {
388
            $this->getPermalink();
389
        }
390
391 1
        return $this->redirects;
392
    }
393
394
    /**
395
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
396
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
397
     *
398
     * @return string
399
     */
400
    private function getPathPermalink ()
401
    {
402
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
403 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->getRelativeFilePath());
404 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
405
406
        // Handle vfs:// paths by replacing their forward slashes with the OS appropriate directory separator
407 3
        if (DIRECTORY_SEPARATOR !== '/')
408 3
        {
409
            $cleanPath = str_replace('/', DIRECTORY_SEPARATOR, $cleanPath);
410
        }
411
412
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
413 3
        $folders = explode(DIRECTORY_SEPARATOR, $cleanPath);
414
415 3
        if (substr($folders[0], 0, 1) === '_')
416 3
        {
417 1
            array_shift($folders);
418 1
        }
419
420 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
421
422 3
        return $cleanPath;
423
    }
424
425
    /**
426
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens
427
     *
428
     * @param  string $permalink A permalink
429
     *
430
     * @return string $permalink The sanitized permalink
431
     */
432
    private function sanitizePermalink ($permalink)
433
    {
434
        // Remove multiple '/' together
435 14
        $permalink = preg_replace('/\/+/', '/', $permalink);
436
437
        // Replace all spaces with hyphens
438 14
        $permalink = str_replace(' ', '-', $permalink);
439
440
        // Remove all disallowed characters
441 14
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
442
443
        // Handle unnecessary extensions
444 14
        $extensionsToStrip = array('twig');
445
446 14
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
447 14
        {
448 4
            $permalink = $this->fs->removeExtension($permalink);
449 4
        }
450
451
        // Remove any special characters before a sane value
452 14
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
453
454
        // Convert permalinks to lower case
455 14
        $permalink = mb_strtolower($permalink, 'UTF-8');
456
457 14
        return $permalink;
458
    }
459
460
    //
461
    // WritableFrontMatter Implementation
462
    //
463
464
    /**
465
     * {@inheritdoc}
466
     */
467
    final public function evaluateFrontMatter ($variables = null)
468
    {
469 2
        if (!is_null($variables))
470 2
        {
471 2
            $this->frontMatter = array_merge($this->frontMatter, $variables);
472 2
            $this->evaluateYaml($this->frontMatter);
473 2
        }
474 2
    }
475
476
    /**
477
     * {@inheritdoc}
478
     */
479
    final public function getFrontMatter ($evaluateYaml = true)
480
    {
481 8
        if (is_null($this->frontMatter))
482 8
        {
483 1
            $this->frontMatter = array();
484 1
        }
485 7
        else if (!$this->frontMatterEvaluated && $evaluateYaml)
486 7
        {
487 7
            $this->evaluateYaml($this->frontMatter);
488 6
        }
489
490 7
        return $this->frontMatter;
491
    }
492
493
    /**
494
     * {@inheritdoc}
495
     */
496
    final public function hasExpandedFrontMatter ()
497
    {
498 1
        return (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion());
499
    }
500
501
    /**
502
     * {@inheritdoc
503
     */
504
    final public function appendFrontMatter (array $frontMatter)
505
    {
506
        foreach ($frontMatter as $key => $value)
507
        {
508
            $this->writableFrontMatter[$key] = $value;
509
        }
510
    }
511
512
    /**
513
     * {@inheritdoc
514
     */
515
    final public function deleteFrontMatter ($key)
516
    {
517
        if (!isset($this->writableFrontMatter[$key])) { return; }
518
519
        unset($this->writableFrontMatter[$key]);
520
    }
521
522
    /**
523
     * {@inheritdoc
524
     */
525
    final public function setFrontMatter (array $frontMatter)
526
    {
527 1
        if (!is_array($frontMatter))
528 1
        {
529
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
530
        }
531
532 1
        $this->writableFrontMatter = $frontMatter;
533 1
    }
534
535
    /**
536
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
537
     *
538
     * @param  array $yaml An array of data containing FrontMatter variables
539
     *
540
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
541
     */
542
    private function evaluateYaml (&$yaml)
543
    {
544 9
        $this->frontMatterParser    = new FrontMatterParser($yaml);
545 8
        $this->frontMatterEvaluated = true;
546 8
    }
547
548
    //
549
    // Jailable Implementation
550
    //
551
552
    /**
553
     * Check if a specific value is defined in the Front Matter
554
     *
555
     * @param  string $name
556
     *
557
     * @return bool
558
     */
559
    public function isMagicGet ($name)
560
    {
561 5
        return (!in_array($name, $this->frontMatterBlacklist)) &&
562 5
               (isset($this->frontMatter[$name]) || isset($this->writableFrontMatter[$name]));
563
    }
564
565
    //
566
    // ArrayAccess Implementation
567
    //
568
569
    /**
570
     * {@inheritdoc}
571
     */
572
    public function offsetSet ($offset, $value)
573
    {
574
        if (is_null($offset))
575
        {
576
            throw new \InvalidArgumentException('$offset cannot be null');
577
        }
578
579
        $this->writableFrontMatter[$offset] = $value;
580
    }
581
582
    /**
583
     * {@inheritdoc}
584
     */
585
    public function offsetExists ($offset)
586
    {
587 2
        return $this->isMagicGet($offset);
588
    }
589
590
    /**
591
     * {@inheritdoc}
592
     */
593
    public function offsetUnset ($offset)
594
    {
595
        unset($this->writableFrontMatter[$offset]);
596
    }
597
598
    /**
599
     * {@inheritdoc}
600
     */
601
    public function offsetGet ($offset)
602
    {
603 2
        if (!$this->isMagicGet($offset))
604 2
        {
605
            $fxnCall = 'get' . ucfirst($offset);
606
607
            return $this->$fxnCall();
608
        }
609
610 2
        if (isset($this->writableFrontMatter[$offset]))
611 2
        {
612 1
            return $this->writableFrontMatter[$offset];
613
        }
614
615 1
        if (isset($this->frontMatter[$offset]))
616 1
        {
617 1
            return $this->frontMatter[$offset];
618
        }
619
620
        return null;
621
    }
622
}