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

Document::hasExpandedFrontMatter()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 2

Importance

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