Completed
Push — master ( b785dd...3df4eb )
by Vladimir
03:46
created

Document::getRelativeFilePath()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 10
ccs 4
cts 4
cp 1
crap 2
rs 9.4285
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 WritableDocumentInterface, JailedDocumentInterface, \ArrayAccess
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
{
23
    const TEMPLATE = "---\n%s\n---\n\n%s";
24
25
    /**
26
     * The names of FrontMatter keys that are specially defined for all Documents
27
     *
28
     * @var array
29
     */
30
    public static $specialFrontMatterKeys = array(
0 ignored issues
show
Comprehensibility Naming introduced by
The variable name $specialFrontMatterKeys exceeds the maximum configured length of 20.

Very long variable names usually make code harder to read. It is therefore recommended not to make variable names too verbose.

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