Completed
Push — master ( 535e15...d4ee79 )
by Vladimir
02:31
created

FrontMatterObject::getPermalink()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 7.0061

Importance

Changes 0
Metric Value
cc 7
eloc 18
nc 10
nop 0
dl 0
loc 33
ccs 19
cts 20
cp 0.95
crap 7.0061
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 84
    public function __construct ($filePath)
134
    {
135 84
        $this->frontMatterBlacklist = array('permalink', 'redirects');
136 84
        $this->writableFrontMatter = array();
137
138 84
        $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 84
        $this->fs        = new Filesystem();
140
141 84
        if (!$this->fs->exists($filePath))
142 84
        {
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
     * Return the body of the Content Item
153
     *
154
     * @return string
155
     */
156
    abstract public function getContent ();
157
158
    /**
159
     * Get the extension of the current file
160
     *
161
     * @return string
162
     */
163
    final public function getExtension ()
164
    {
165 4
        return $this->extension;
166
    }
167
168
    /**
169
     * Get the original file path
170
     *
171
     * @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...
172
     */
173
    final public function getFilePath ()
174
    {
175 3
        return $this->filePath;
176
    }
177
178
    /**
179
     * The number of lines that are taken up by FrontMatter and white space
180
     *
181
     * @return int
182
     */
183
    final public function getLineOffset ()
184
    {
185
        return $this->lineOffset;
186
    }
187
188
    /**
189
     * Get the name of the item, which is just the file name without the extension
190
     *
191
     * @return string
192
     */
193
    final public function getName ()
194
    {
195 30
        return $this->fs->getBaseName($this->filePath);
196
    }
197
198
    /**
199
     * Get the relative path to this file relative to the root of the Stakx website
200
     *
201
     * @return string
202
     */
203
    final public function getRelativeFilePath ()
204
    {
205 44
        if ($this->filePath instanceof SplFileInfo)
206 44
        {
207 31
            return $this->filePath->getRelativePathname();
208
        }
209
210
        // 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...
211 13
        return $this->fs->getRelativePath($this->filePath);
212
    }
213
214
    /**
215
     * Get the destination of where this Content Item would be written to when the website is compiled
216
     *
217
     * @return string
218
     */
219
    final public function getTargetFile ()
220
    {
221 7
        $permalink = $this->getPermalink();
222 7
        $extension = $this->fs->getExtension($permalink);
223 7
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
224
225 7
        if (empty($extension))
226 7
        {
227 3
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
228 3
        }
229
230 7
        return ltrim($permalink, DIRECTORY_SEPARATOR);
231
    }
232
233
    /**
234
     * Check whether this object has a reference to a collection or data item
235
     *
236
     * @param  string $namespace 'collections' or 'data'
237
     * @param  string $needle
238
     *
239
     * @return bool
240
     */
241
    final public function hasTwigDependency ($namespace, $needle)
242
    {
243
        return (in_array($needle, $this->dataDependencies[$namespace]));
244
    }
245
246
    /**
247
     * Read the file, and parse its contents
248
     */
249
    final public function refreshFileContent ()
250
    {
251
        // This function can be called after the initial object was created and the file may have been deleted since the
252
        // creation of the object.
253 83
        if (!$this->fs->exists($this->filePath))
254 83
        {
255 1
            throw new FileNotFoundException(null, 0, null, $this->filePath);
256
        }
257
258 83
        $rawFileContents = file_get_contents($this->filePath);
259 83
        $fileStructure   = array();
260 83
        preg_match('/---\R(.*?\R)?---(\s+)(.*)/s', $rawFileContents, $fileStructure);
261
262 83
        if (count($fileStructure) != 4)
263 83
        {
264 9
            throw new InvalidSyntaxException('Invalid FrontMatter file', 0, null, $this->getRelativeFilePath());
265
        }
266
267 74
        if (empty(trim($fileStructure[3])))
268 74
        {
269 1
            throw new InvalidSyntaxException('FrontMatter files must have a body to render', 0, null, $this->getRelativeFilePath());
270
        }
271
272 73
        $this->lineOffset  = substr_count($fileStructure[1], "\n") + substr_count($fileStructure[2], "\n");
273 73
        $this->bodyContent = $fileStructure[3];
274
275 73
        if (!empty(trim($fileStructure[1])))
276 73
        {
277 60
            $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...
278
279 60
            if (!empty($this->frontMatter) && !is_array($this->frontMatter))
280 60
            {
281 1
                throw new ParseException('The evaluated FrontMatter should be an array');
282
            }
283 59
        }
284
        else
285
        {
286 13
            $this->frontMatter = array();
287
        }
288
289 72
        $this->frontMatterEvaluated = false;
290 72
        $this->bodyContentEvaluated = false;
291 72
        $this->permalink = null;
292
293 72
        $this->findTwigDataDependencies('collections');
294 72
        $this->findTwigDataDependencies('data');
295 72
    }
296
297
    /**
298
     * Get all of the references to either DataItems or ContentItems inside of given string
299
     *
300
     * @param string $filter 'collections' or 'data'
301
     */
302
    private function findTwigDataDependencies ($filter)
303
    {
304 72
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
305 72
        $results = array();
306
307 72
        preg_match_all($regex, $this->bodyContent, $results);
308
309 72
        $this->dataDependencies[$filter] = array_unique($results[1]);
310 72
    }
311
312
    //
313
    // Permalink and redirect functionality
314
    //
315
316
    /**
317
     * Get the permalink of this Content Item
318
     *
319
     * @return string
320
     * @throws \Exception
321
     */
322
    final public function getPermalink ()
323
    {
324 18
        if (!is_null($this->permalink))
325 18
        {
326 3
            return $this->permalink;
327
        }
328
329 18
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
330 18
        {
331
            throw new \Exception('The permalink for this item has not been set');
332
        }
333
334 18
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
335 18
            $this->frontMatter['permalink'] : $this->getPathPermalink();
336
337 18
        if (is_array($permalink))
338 18
        {
339 6
            $this->permalink = $permalink[0];
340 6
            array_shift($permalink);
341 6
            $this->redirects = $permalink;
342 6
        }
343
        else
344
        {
345 12
            $this->permalink = $permalink;
346 12
            $this->redirects = array();
347
        }
348
349 18
        $this->permalink = $this->sanitizePermalink($this->permalink);
350 18
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
351 18
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
352
353 18
        return $this->permalink;
354
    }
355
356
    /**
357
     * Get an array of URLs that will redirect to
358
     *
359
     * @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...
360
     */
361
    final public function getRedirects ()
362
    {
363 3
        if (is_null($this->redirects))
364 3
        {
365 1
            $this->getPermalink();
366 1
        }
367
368 3
        return $this->redirects;
369
    }
370
371
    /**
372
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
373
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
374
     *
375
     * @return string
376
     */
377
    private function getPathPermalink ()
378
    {
379
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
380 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->getRelativeFilePath());
381 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
382
383
        // Handle vfs:// paths by replacing their forward slashes with the OS appropriate directory separator
384 3
        if (DIRECTORY_SEPARATOR !== '/')
385 3
        {
386
            $cleanPath = str_replace('/', DIRECTORY_SEPARATOR, $cleanPath);
387
        }
388
389
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
390 3
        $folders = explode(DIRECTORY_SEPARATOR, $cleanPath);
391
392 3
        if (substr($folders[0], 0, 1) === '_')
393 3
        {
394 1
            array_shift($folders);
395 1
        }
396
397 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
398
399 3
        return $cleanPath;
400
    }
401
402
    /**
403
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens
404
     *
405
     * @param  string $permalink A permalink
406
     *
407
     * @return string $permalink The sanitized permalink
408
     */
409
    private function sanitizePermalink ($permalink)
410
    {
411
        // Remove multiple '/' together
412 18
        $permalink = preg_replace('/\/+/', '/', $permalink);
413
414
        // Replace all spaces with hyphens
415 18
        $permalink = str_replace(' ', '-', $permalink);
416
417
        // Remove all disallowed characters
418 18
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
419
420
        // Handle unnecessary extensions
421 18
        $extensionsToStrip = array('twig');
422
423 18
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
424 18
        {
425 4
            $permalink = $this->fs->removeExtension($permalink);
426 4
        }
427
428
        // Remove any special characters before a sane value
429 18
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
430
431
        // Convert permalinks to lower case
432 18
        $permalink = mb_strtolower($permalink, 'UTF-8');
433
434 18
        return $permalink;
435
    }
436
437
    //
438
    // WritableFrontMatter Implementation
439
    //
440
441
    /**
442
     * {@inheritdoc}
443
     */
444
    final public function evaluateFrontMatter ($variables = null)
445
    {
446 2
        if (!is_null($variables))
447 2
        {
448 2
            $this->frontMatter = array_merge($this->frontMatter, $variables);
449 2
            $this->evaluateYaml($this->frontMatter);
450 2
        }
451 2
    }
452
453
    /**
454
     * {@inheritdoc}
455
     */
456
    final public function getFrontMatter ($evaluateYaml = true)
457
    {
458 12
        if (is_null($this->frontMatter))
459 12
        {
460
            $this->frontMatter = array();
461
        }
462 12
        else if (!$this->frontMatterEvaluated && $evaluateYaml)
463 12
        {
464 12
            $this->evaluateYaml($this->frontMatter);
465 11
        }
466
467 11
        return $this->frontMatter;
468
    }
469
470
    /**
471
     * {@inheritdoc}
472
     */
473
    final public function hasExpandedFrontMatter ()
474
    {
475 1
        return (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion());
476
    }
477
478
    /**
479
     * {@inheritdoc
480
     */
481
    final public function appendFrontMatter (array $frontMatter)
482
    {
483
        foreach ($frontMatter as $key => $value)
484
        {
485
            $this->writableFrontMatter[$key] = $value;
486
        }
487
    }
488
489
    /**
490
     * {@inheritdoc
491
     */
492
    final public function deleteFrontMatter ($key)
493
    {
494
        if (!isset($this->writableFrontMatter[$key])) { return; }
495
496
        unset($this->writableFrontMatter[$key]);
497
    }
498
499
    /**
500
     * {@inheritdoc
501
     */
502
    final public function setFrontMatter (array $frontMatter)
503
    {
504 1
        if (!is_array($frontMatter))
505 1
        {
506
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
507
        }
508
509 1
        $this->writableFrontMatter = $frontMatter;
510 1
    }
511
512
    /**
513
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
514
     *
515
     * @param  array $yaml An array of data containing FrontMatter variables
516
     *
517
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
518
     */
519
    private function evaluateYaml (&$yaml)
520
    {
521 14
        $this->frontMatterParser    = new FrontMatterParser($yaml);
522 13
        $this->frontMatterEvaluated = true;
523 13
    }
524
525
    //
526
    // ArrayAccess Implementation
527
    //
528
529
    /**
530
     * {@inheritdoc}
531
     */
532
    public function offsetSet ($offset, $value)
533
    {
534
        if (is_null($offset))
535
        {
536
            throw new \InvalidArgumentException('$offset cannot be null');
537
        }
538
539
        $this->writableFrontMatter[$offset] = $value;
540
    }
541
542
    /**
543
     * {@inheritdoc}
544
     */
545
    public function offsetExists ($offset)
546
    {
547 26
        if (isset($this->writableFrontMatter[$offset]) || isset($this->frontMatter[$offset]))
548 26
        {
549 25
            return true;
550
        }
551
552 7
        $fxnCall = 'get' . ucfirst($offset);
553 7
        return method_exists($this, $fxnCall) && in_array($fxnCall, static::$whiteListFunctions);
554
    }
555
556
    /**
557
     * {@inheritdoc}
558
     */
559
    public function offsetUnset ($offset)
560
    {
561
        unset($this->writableFrontMatter[$offset]);
562
    }
563
564
    /**
565
     * {@inheritdoc}
566
     */
567
    public function offsetGet ($offset)
568
    {
569 36
        $fxnCall = 'get' . ucfirst($offset);
570
571 36
        if (in_array($fxnCall, self::$whiteListFunctions) && method_exists($this, $fxnCall))
572 36
        {
573 4
            return call_user_func_array(array($this, $fxnCall), array());
574
        }
575
576 33
        if (isset($this->writableFrontMatter[$offset]))
577 33
        {
578 1
            return $this->writableFrontMatter[$offset];
579
        }
580
581 32
        if (isset($this->frontMatter[$offset]))
582 32
        {
583 31
            return $this->frontMatter[$offset];
584
        }
585
586 2
        return null;
587
    }
588
}