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

FrontMatterObject::getExtension()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
ccs 1
cts 1
cp 1
crap 1
rs 10
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 94
    public function __construct ($filePath)
135
    {
136 94
        $this->frontMatterBlacklist = array('permalink', 'redirects');
137 94
        $this->writableFrontMatter = array();
138
139 94
        $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 94
        $this->fs        = new Filesystem();
141
142 94
        if (!$this->fs->exists($filePath))
143
        {
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 7
        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 2
        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
        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 30
        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 44
        if ($this->filePath instanceof SplFileInfo)
207
        {
208 30
            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 14
        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 6
        $permalink = $this->getPermalink();
223 6
        $missingFile = (substr($permalink, -1) == '/');
224 6
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
225
226 6
        if ($missingFile)
227
        {
228 2
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
229
        }
230
231 6
        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 93
        if (!$this->fs->exists($this->filePath))
255
        {
256 1
            throw new FileNotFoundException(null, 0, null, $this->filePath);
257
        }
258
259 93
        $rawFileContents = file_get_contents($this->filePath);
260 93
        $fileStructure   = array();
261 93
        preg_match('/---\R(.*?\R)?---(\s+)(.*)/s', $rawFileContents, $fileStructure);
262
263 93
        if (count($fileStructure) != 4)
264
        {
265 9
            throw new InvalidSyntaxException('Invalid FrontMatter file', 0, null, $this->getRelativeFilePath());
266
        }
267
268 84
        if (empty(trim($fileStructure[3])))
269
        {
270 1
            throw new InvalidSyntaxException('FrontMatter files must have a body to render', 0, null, $this->getRelativeFilePath());
271
        }
272
273 83
        $this->lineOffset  = substr_count($fileStructure[1], "\n") + substr_count($fileStructure[2], "\n");
274 83
        $this->bodyContent = $fileStructure[3];
275
276 83
        if (!empty(trim($fileStructure[1])))
277
        {
278 67
            $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 67
            if (!empty($this->frontMatter) && !is_array($this->frontMatter))
281
            {
282 67
                throw new ParseException('The evaluated FrontMatter should be an array');
283
            }
284
        }
285
        else
286
        {
287 16
            $this->frontMatter = array();
288
        }
289
290 82
        $this->frontMatterEvaluated = false;
291 82
        $this->bodyContentEvaluated = false;
292 82
        $this->permalink = null;
293
294 82
        $this->findTwigDataDependencies('collections');
295 82
        $this->findTwigDataDependencies('data');
296 82
    }
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 82
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
306 82
        $results = array();
307
308 82
        preg_match_all($regex, $this->bodyContent, $results);
309
310 82
        $this->dataDependencies[$filter] = array_unique($results[1]);
311 82
    }
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 25
        if (!is_null($this->permalink))
326
        {
327 2
            return $this->permalink;
328
        }
329
330 25
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
331
        {
332
            throw new \Exception('The permalink for this item has not been set');
333
        }
334
335 25
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
336 25
            $this->frontMatter['permalink'] : $this->getPathPermalink();
337
338 25
        if (is_array($permalink))
339
        {
340 13
            $this->permalink = $permalink[0];
341 13
            array_shift($permalink);
342 13
            $this->redirects = $permalink;
343
        }
344
        else
345
        {
346 12
            $this->permalink = $permalink;
347 12
            $this->redirects = array();
348
        }
349
350 25
        $this->permalink = $this->sanitizePermalink($this->permalink);
351 25
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
352 25
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
353
354 25
        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
        {
366 1
            $this->getPermalink();
367
        }
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
        {
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
        {
395 1
            array_shift($folders);
396
        }
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 25
        $permalink = preg_replace('/\/+/', '/', $permalink);
414
415
        // Replace all spaces with hyphens
416 25
        $permalink = str_replace(' ', '-', $permalink);
417
418
        // Remove all disallowed characters
419 25
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
420
421
        // Handle unnecessary extensions
422 25
        $extensionsToStrip = array('twig');
423
424 25
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
425
        {
426 4
            $permalink = $this->fs->removeExtension($permalink);
427
        }
428
429
        // Remove any special characters before a sane value
430 25
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
431
432
        // Convert permalinks to lower case
433 25
        $permalink = mb_strtolower($permalink, 'UTF-8');
434
435 25
        return $permalink;
436
    }
437
438
    //
439
    // WritableFrontMatter Implementation
440
    //
441
442
    /**
443
     * {@inheritdoc}
444
     */
445
    final public function evaluateFrontMatter ($variables = null)
446
    {
447 2
        if (!is_null($variables))
448
        {
449 2
            $this->frontMatter = array_merge($this->frontMatter, $variables);
450 2
            $this->evaluateYaml($this->frontMatter);
451
        }
452 2
    }
453
454
    /**
455
     * {@inheritdoc}
456
     */
457
    final public function getFrontMatter ($evaluateYaml = true)
458
    {
459 19
        if (is_null($this->frontMatter))
460
        {
461
            $this->frontMatter = array();
462
        }
463 19
        else if (!$this->frontMatterEvaluated && $evaluateYaml)
464
        {
465 19
            $this->evaluateYaml($this->frontMatter);
466
        }
467
468 18
        return $this->frontMatter;
469
    }
470
471
    /**
472
     * {@inheritdoc}
473
     */
474
    final public function hasExpandedFrontMatter ()
475
    {
476
        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 21
            $this->frontMatterParser    = new FrontMatterParser($yaml);
525 20
            $this->frontMatterEvaluated = true;
526
        }
527 1
        catch (\Exception $e)
528
        {
529 1
            throw FileAwareException::castException($e, $this->getRelativeFilePath());
530
        }
531 20
    }
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 25
        if (isset($this->writableFrontMatter[$offset]) || isset($this->frontMatter[$offset]))
556
        {
557 24
            return true;
558
        }
559
560 7
        $fxnCall = 'get' . ucfirst($offset);
561 7
        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 38
        $fxnCall = 'get' . ucfirst($offset);
578
579 38
        if (in_array($fxnCall, self::$whiteListFunctions) && method_exists($this, $fxnCall))
580
        {
581 6
            return call_user_func_array(array($this, $fxnCall), array());
582
        }
583
584 32
        if (isset($this->writableFrontMatter[$offset]))
585
        {
586
            return $this->writableFrontMatter[$offset];
587
        }
588
589 32
        if (isset($this->frontMatter[$offset]))
590
        {
591 31
            return $this->frontMatter[$offset];
592
        }
593
594 2
        return null;
595
    }
596
}