Completed
Push — master ( 759c44...756561 )
by Vladimir
02:31
created

FrontMatterObject::refreshFileContent()   C

Complexity

Conditions 7
Paths 6

Size

Total Lines 47
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 23
nc 6
nop 0
dl 0
loc 47
ccs 28
cts 28
cp 1
crap 7
rs 6.7272
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 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)?---(\s+)(.*)/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->bodyContent = $fileStructure[3];
305
306 43
        if (!empty(trim($fileStructure[1])))
307 43
        {
308 30
            $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 30
            if (!empty($this->frontMatter) && !is_array($this->frontMatter))
311 30
            {
312 1
                throw new ParseException('The evaluated FrontMatter should be an array');
313
            }
314 29
        }
315
        else
316
        {
317 13
            $this->frontMatter = array();
318
        }
319
320 42
        $this->frontMatterEvaluated = false;
321 42
        $this->bodyContentEvaluated = false;
322 42
        $this->permalink = null;
323
324 42
        $this->findTwigDataDependencies('collections');
325 42
        $this->findTwigDataDependencies('data');
326 42
    }
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 42
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
336 42
        $results = array();
337
338 42
        preg_match_all($regex, $this->bodyContent, $results);
339
340 42
        $this->dataDependencies[$filter] = array_unique($results[1]);
341 42
    }
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 15
        if (!is_null($this->permalink))
356 15
        {
357 2
            return $this->permalink;
358
        }
359
360 14
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
361 14
        {
362
            throw new \Exception('The permalink for this item has not been set');
363
        }
364
365 14
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
366 14
            $this->frontMatter['permalink'] : $this->getPathPermalink();
367
368 14
        if (is_array($permalink))
369 14
        {
370 3
            $this->permalink = $permalink[0];
371 3
            array_shift($permalink);
372 3
            $this->redirects = $permalink;
373 3
        }
374
        else
375
        {
376 11
            $this->permalink = $permalink;
377 11
            $this->redirects = array();
378
        }
379
380 14
        $this->permalink = $this->sanitizePermalink($this->permalink);
381 14
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
382 14
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
383
384 14
        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 1
        if (is_null($this->redirects))
395 1
        {
396
            $this->getPermalink();
397
        }
398
399 1
        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 14
        $permalink = preg_replace('/\/+/', '/', $permalink);
444
445
        // Replace all spaces with hyphens
446 14
        $permalink = str_replace(' ', '-', $permalink);
447
448
        // Remove all disallowed characters
449 14
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
450
451
        // Handle unnecessary extensions
452 14
        $extensionsToStrip = array('twig');
453
454 14
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
455 14
        {
456 4
            $permalink = $this->fs->removeExtension($permalink);
457 4
        }
458
459
        // Remove any special characters before a sane value
460 14
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
461
462
        // Convert permalinks to lower case
463 14
        $permalink = mb_strtolower($permalink, 'UTF-8');
464
465 14
        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 5
        return (!in_array($name, $this->frontMatterBlacklist)) &&
570 5
               (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 2
        return $this->isMagicGet($offset);
596
    }
597
598
    /**
599
     * {@inheritdoc}
600
     */
601
    public function offsetUnset ($offset)
602
    {
603
        unset($this->writableFrontMatter[$offset]);
604
    }
605
606
    /**
607
     * {@inheritdoc}
608
     */
609
    public function offsetGet ($offset)
610
    {
611 2
        if (!$this->isMagicGet($offset))
612 2
        {
613
            $fxnCall = 'get' . ucfirst($offset);
614
615
            return $this->$fxnCall();
616
        }
617
618 2
        if (isset($this->writableFrontMatter[$offset]))
619 2
        {
620 1
            return $this->writableFrontMatter[$offset];
621
        }
622
623 1
        if (isset($this->frontMatter[$offset]))
624 1
        {
625 1
            return $this->frontMatter[$offset];
626
        }
627
628
        return null;
629
    }
630
}