Completed
Pull Request — master (#48)
by Vladimir
04:25 queued 01:50
created

Document::getPermalink()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7.0119

Importance

Changes 0
Metric Value
cc 7
eloc 18
nc 10
nop 0
dl 0
loc 33
ccs 15
cts 16
cp 0.9375
crap 7.0119
rs 6.7272
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * @copyright 2017 Vladimir Jimenez
5
 * @license   https://github.com/allejo/stakx/blob/master/LICENSE.md MIT
6
 */
7
8
namespace allejo\stakx\FrontMatter;
9
10
use allejo\stakx\Document\JailedDocumentInterface;
11
use allejo\stakx\Exception\FileAwareException;
12
use allejo\stakx\Exception\InvalidSyntaxException;
13
use allejo\stakx\FrontMatter\Exception\YamlVariableUndefinedException;
14
use allejo\stakx\System\Filesystem;
15
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
16
use Symfony\Component\Filesystem\Exception\IOException;
17
use Symfony\Component\Finder\SplFileInfo;
18
use Symfony\Component\Yaml\Exception\ParseException;
19
use Symfony\Component\Yaml\Yaml;
20
21
abstract class Document implements
0 ignored issues
show
Coding Style introduced by
Document does not seem to conform to the naming convention (^Abstract|Factory$).

This check examines a number of code elements and verifies that they conform to the given naming conventions.

You can set conventions for local variables, abstract classes, utility classes, constant, properties, methods, parameters, interfaces, classes, exceptions and special methods.

Loading history...
22
    \ArrayAccess,
23
    JailedDocumentInterface,
24
    WritableDocumentInterface
25
{
26
    const TEMPLATE = "---\n%s\n---\n\n%s";
27
28
    protected static $whiteListFunctions = array(
29
        'getPermalink', 'getRedirects', 'getTargetFile', 'getName', 'getFilePath', 'getRelativeFilePath', 'getContent',
30
        'getExtension', 'getFrontMatter'
31
    );
32
33
    /**
34
     * An array to keep track of collection or data dependencies used inside of a Twig template.
35
     *
36
     * $dataDependencies['collections'] = array()
37
     * $dataDependencies['data'] = array()
38
     *
39
     * @var array
40
     */
41
    protected $dataDependencies;
42
43
    /**
44
     * FrontMatter values that can be injected or set after the file has been parsed. Values in this array will take
45
     * precedence over values in $frontMatter.
46
     *
47
     * @var array
48
     */
49
    protected $writableFrontMatter;
50
51
    /**
52
     * A list of Front Matter values that should not be returned directly from the $frontMatter array. Values listed
53
     * here have dedicated functions that handle those Front Matter values and the respective functions should be called
54
     * instead.
55
     *
56
     * @var string[]
57
     */
58
    protected $frontMatterBlacklist;
59
60
    /**
61
     * Set to true if the front matter has already been evaluated with variable interpolation.
62
     *
63
     * @var bool
64
     */
65
    protected $frontMatterEvaluated;
66
67
    /**
68
     * @var Parser
69
     */
70
    protected $frontMatterParser;
71
72
    /**
73
     * An array containing the Yaml of the file.
74
     *
75
     * @var array
76
     */
77
    protected $frontMatter;
78
79
    /**
80
     * Set to true if the body has already been parsed as markdown or any other format.
81
     *
82
     * @var bool
83
     */
84
    protected $bodyContentEvaluated;
85
86
    /**
87
     * Only the body of the file, i.e. the content.
88
     *
89
     * @var string
90
     */
91
    protected $bodyContent;
92
93
    /**
94
     * The permalink for this object.
95
     *
96
     * @var string
97
     */
98
    protected $permalink;
99
100
    /**
101
     * A filesystem object.
102
     *
103
     * @var Filesystem
104
     */
105
    protected $fs;
0 ignored issues
show
Comprehensibility introduced by
Avoid variables with short names like $fs. Configured minimum length is 3.

Short variable names may make your code harder to understand. Variable names should be self-descriptive. This check looks for variable names who are shorter than a configured minimum.

Loading history...
106
107
    /**
108
     * The extension of the file.
109
     *
110
     * @var string
111
     */
112
    private $extension;
113
114
    /**
115
     * The number of lines that Twig template errors should offset.
116
     *
117
     * @var int
118
     */
119
    private $lineOffset;
120
121
    /**
122
     * A list URLs that will redirect to this object.
123
     *
124
     * @var string[]
125
     */
126
    private $redirects;
127
128
    /**
129
     * The original file path to the ContentItem.
130
     *
131
     * @var SplFileInfo
132
     */
133
    private $filePath;
134
135
    /**
136
     * ContentItem constructor.
137
     *
138
     * @param string $filePath The path to the file that will be parsed into a ContentItem
139
     *
140
     * @throws FileNotFoundException The given file path does not exist
141
     * @throws IOException           The file was not a valid ContentItem. This would meam there was no front matter or
142
     *                               no body
143
     */
144 116
    public function __construct($filePath)
145
    {
146 116
        $this->frontMatterBlacklist = array('permalink', 'redirects');
147 116
        $this->writableFrontMatter = array();
148
149 116
        $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...
150 116
        $this->fs = new Filesystem();
151
152 116
        if (!$this->fs->exists($filePath))
153
        {
154 1
            throw new FileNotFoundException("The following file could not be found: ${filePath}");
155
        }
156
157
        $this->extension = strtolower($this->fs->getExtension($filePath));
158
159
        $this->refreshFileContent();
160
    }
161
162
    /**
163
     * Return the body of the Content Item.
164
     *
165
     * @return string
166
     */
167
    abstract public function getContent();
168
169
    /**
170
     * Get the extension of the current file.
171
     *
172
     * @return string
173
     */
174
    final public function getExtension()
175
    {
176 7
        return $this->extension;
177
    }
178
179
    /**
180
     * Get the original file path.
181
     *
182
     * @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...
183
     */
184
    final public function getFilePath()
185
    {
186 19
        return $this->filePath;
187
    }
188
189
    /**
190
     * The number of lines that are taken up by FrontMatter and white space.
191
     *
192
     * @return int
193
     */
194
    final public function getLineOffset()
195
    {
196
        return $this->lineOffset;
197
    }
198
199
    /**
200
     * Get the name of the item, which is just the file name without the extension.
201
     *
202
     * @return string
203
     */
204
    final public function getName()
205
    {
206 37
        return $this->fs->getBaseName($this->filePath);
207
    }
208
209
    /**
210
     * Get the relative path to this file relative to the root of the Stakx website.
211
     *
212
     * @return string
213
     */
214
    final public function getRelativeFilePath()
215
    {
216 65
        if ($this->filePath instanceof SplFileInfo)
217
        {
218 39
            return $this->filePath->getRelativePathname();
219
        }
220
221
        // TODO ensure that we always get SplFileInfo objects, even when handling VFS documents
222 28
        return $this->fs->getRelativePath($this->filePath);
223
    }
224
225
    /**
226
     * Get the destination of where this Content Item would be written to when the website is compiled.
227
     *
228
     * @return string
229
     */
230
    final public function getTargetFile()
231
    {
232 20
        $permalink = $this->getPermalink();
233 20
        $missingFile = (substr($permalink, -1) == '/');
234 20
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
235
236 20
        if ($missingFile)
237
        {
238 12
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
239
        }
240
241 20
        return ltrim($permalink, DIRECTORY_SEPARATOR);
242
    }
243
244
    /**
245
     * Check whether this object has a reference to a collection or data item.
246
     *
247
     * @param string $namespace 'collections' or 'data'
248
     * @param string $needle
249
     *
250
     * @return bool
251
     */
252
    final public function hasTwigDependency($namespace, $needle)
253
    {
254
        return in_array($needle, $this->dataDependencies[$namespace]);
255
    }
256
257
    /**
258
     * Read the file, and parse its contents.
259
     */
260
    final public function refreshFileContent()
261
    {
262
        // This function can be called after the initial object was created and the file may have been deleted since the
263
        // creation of the object.
264 115 View Code Duplication
        if (!$this->fs->exists($this->filePath))
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
265
        {
266 1
            throw new FileNotFoundException(null, 0, null, $this->filePath);
267
        }
268
269
        // $fileStructure[1] is the YAML
0 ignored issues
show
Unused Code Comprehensibility introduced by
37% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
270
        // $fileStructure[2] is the amount of new lines after the closing `---` and the beginning of content
271
        // $fileStructure[3] is the body of the document
272 115
        $fileStructure = array();
273
274 115
        $rawFileContents = file_get_contents($this->filePath);
275 115
        preg_match('/---\R(.*?\R)?---(\s+)(.*)/s', $rawFileContents, $fileStructure);
276
277 115
        if (count($fileStructure) != 4)
278
        {
279 9
            throw new InvalidSyntaxException('Invalid FrontMatter file', 0, null, $this->getRelativeFilePath());
280
        }
281
282 106
        if (empty(trim($fileStructure[3])))
283
        {
284 1
            throw new InvalidSyntaxException('FrontMatter files must have a body to render', 0, null, $this->getRelativeFilePath());
285
        }
286
287
        // The hard coded 1 is the offset used to count the new line used after the first `---` that is not caught in the regex
288 105
        $this->lineOffset = substr_count($fileStructure[1], "\n") + substr_count($fileStructure[2], "\n") + 1;
289 105
        $this->bodyContent = $fileStructure[3];
290
291 105
        if (!empty(trim($fileStructure[1])))
292
        {
293 89
            $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...
294
295 89
            if (!empty($this->frontMatter) && !is_array($this->frontMatter))
296
            {
297 1
                throw new ParseException('The evaluated FrontMatter should be an array');
298
            }
299
        }
300
        else
301
        {
302 19
            $this->frontMatter = array();
303
        }
304
305 104
        $this->frontMatterEvaluated = false;
306 104
        $this->bodyContentEvaluated = false;
307 104
        $this->permalink = null;
308
309 104
        $this->findTwigDataDependencies('collections');
310 104
        $this->findTwigDataDependencies('data');
311 104
    }
312
313
    /**
314
     * Get all of the references to either DataItems or ContentItems inside of given string.
315
     *
316
     * @param string $filter 'collections' or 'data'
317
     */
318
    private function findTwigDataDependencies($filter)
319
    {
320 104
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
321 104
        $results = array();
322
323 104
        preg_match_all($regex, $this->bodyContent, $results);
324
325 104
        $this->dataDependencies[$filter] = array_unique($results[1]);
326 104
    }
327
328
    //
329
    // Permalink and redirect functionality
330
    //
331
332
    /**
333
     * Get the permalink of this Content Item.
334
     *
335
     * @throws \Exception
336
     *
337
     * @return string
338
     */
339
    final public function getPermalink()
340
    {
341 39
        if (!is_null($this->permalink))
342
        {
343 8
            return $this->permalink;
344
        }
345
346 37
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
347
        {
348
            throw new \Exception('The permalink for this item has not been set');
349
        }
350
351 37
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
352 37
            $this->frontMatter['permalink'] : $this->getPathPermalink();
353
354 37
        if (is_array($permalink))
355
        {
356 19
            $this->permalink = $permalink[0];
357 19
            array_shift($permalink);
358 19
            $this->redirects = $permalink;
359
        }
360
        else
361
        {
362 24
            $this->permalink = $permalink;
363 24
            $this->redirects = array();
364
        }
365
366 37
        $this->permalink = $this->sanitizePermalink($this->permalink);
367 37
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
368 37
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
369
370 37
        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 14
        if (is_null($this->redirects))
381
        {
382 3
            $this->getPermalink();
383
        }
384
385 14
        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
        {
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
        {
411 1
            array_shift($folders);
412
        }
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 37
        $permalink = preg_replace('/\/+/', '/', $permalink);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $permalink. This often makes code more readable.
Loading history...
430
431
        // Replace all spaces with hyphens
432 37
        $permalink = str_replace(' ', '-', $permalink);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $permalink. This often makes code more readable.
Loading history...
433
434
        // Remove all disallowed characters
435 37
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $permalink. This often makes code more readable.
Loading history...
436
437
        // Handle unnecessary extensions
438 37
        $extensionsToStrip = array('twig');
439
440 37
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
441
        {
442 4
            $permalink = $this->fs->removeExtension($permalink);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $permalink. This often makes code more readable.
Loading history...
443
        }
444
445
        // Remove any special characters before a sane value
446 37
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $permalink. This often makes code more readable.
Loading history...
447
448
        // Convert permalinks to lower case
449 37
        $permalink = mb_strtolower($permalink, 'UTF-8');
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $permalink. This often makes code more readable.
Loading history...
450
451 37
        return $permalink;
452
    }
453
454
    //
455
    // WritableFrontMatter Implementation
456
    //
457
458
    /**
459
     * {@inheritdoc}
460
     */
461
    final public function evaluateFrontMatter($variables = null)
462
    {
463 7
        if (!is_null($variables))
464
        {
465 7
            $this->frontMatter = array_merge($this->frontMatter, $variables);
466 7
            $this->evaluateYaml($this->frontMatter);
467
        }
468 7
    }
469
470
    /**
471
     * {@inheritdoc}
472
     */
473
    final public function getFrontMatter($evaluateYaml = true)
474
    {
475 29
        if (is_null($this->frontMatter))
476
        {
477
            $this->frontMatter = array();
478
        }
479 29
        elseif (!$this->frontMatterEvaluated && $evaluateYaml)
480
        {
481 23
            $this->evaluateYaml($this->frontMatter);
482
        }
483
484 28
        return $this->frontMatter;
485
    }
486
487
    /**
488
     * {@inheritdoc}
489
     */
490
    final public function hasExpandedFrontMatter()
491
    {
492 2
        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]))
512
        {
513
            return;
514
        }
515
516
        unset($this->writableFrontMatter[$key]);
517
    }
518
519
    /**
520
     * {@inheritdoc.
521
     */
522
    final public function setFrontMatter(array $frontMatter)
523
    {
524 2
        if (!is_array($frontMatter))
525
        {
526
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
527
        }
528
529 2
        $this->writableFrontMatter = $frontMatter;
530 2
    }
531
532
    /**
533
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
534
     *
535
     * @param array $yaml An array of data containing FrontMatter variables
536
     *
537
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
538
     */
539
    private function evaluateYaml(&$yaml)
540
    {
541
        try
542
        {
543 30
            $this->frontMatterParser = new Parser($yaml);
544 29
            $this->frontMatterEvaluated = true;
545
        }
546 1
        catch (\Exception $e)
547
        {
548 1
            throw FileAwareException::castException($e, $this->getRelativeFilePath());
549
        }
550 29
    }
551
552
    //
553
    // ArrayAccess Implementation
554
    //
555
556
    /**
557
     * {@inheritdoc}
558
     */
559
    public function offsetSet($offset, $value)
560
    {
561
        if (is_null($offset))
562
        {
563
            throw new \InvalidArgumentException('$offset cannot be null');
564
        }
565
566
        $this->writableFrontMatter[$offset] = $value;
567
    }
568
569
    /**
570
     * {@inheritdoc}
571
     */
572
    public function offsetExists($offset)
573
    {
574 31
        if (isset($this->writableFrontMatter[$offset]) || isset($this->frontMatter[$offset]))
575
        {
576 30
            return true;
577
        }
578
579 14
        $fxnCall = 'get' . ucfirst($offset);
580
581 14
        return method_exists($this, $fxnCall) && in_array($fxnCall, static::$whiteListFunctions);
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 48
        $fxnCall = 'get' . ucfirst($offset);
598
599 48
        if (in_array($fxnCall, self::$whiteListFunctions) && method_exists($this, $fxnCall))
600
        {
601 6
            return call_user_func_array(array($this, $fxnCall), array());
602
        }
603
604 42
        if (isset($this->writableFrontMatter[$offset]))
605
        {
606
            return $this->writableFrontMatter[$offset];
607
        }
608
609 42
        if (isset($this->frontMatter[$offset]))
610
        {
611 41
            return $this->frontMatter[$offset];
612
        }
613
614 5
        return null;
615
    }
616
}
617