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

FrontMatterObject::offsetGet()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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