Completed
Pull Request — master (#41)
by Vladimir
02:38
created

FrontMatterObject::getRelativeFilePath()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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