Completed
Pull Request — master (#41)
by Vladimir
04:25
created

FrontMatterObject::getLineOffset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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