Completed
Push — master ( adff0c...fcdb68 )
by Vladimir
02:58
created

FrontMatterObject::getRelativeFilePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
ccs 1
cts 1
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace allejo\stakx\Object;
4
5
use allejo\stakx\System\Filesystem;
6
use allejo\stakx\Exception\YamlVariableUndefinedException;
7
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
8
use Symfony\Component\Filesystem\Exception\IOException;
9
use Symfony\Component\Yaml\Yaml;
10
11
abstract class FrontMatterObject
12
{
13
    /**
14
     * An array to keep track of collection or data dependencies used inside of a Twig template
15
     *
16
     * $dataDependencies['collections'] = array()
17
     * $dataDependencies['data'] = array()
18
     *
19
     * @var array
20
     */
21
    protected $dataDependencies;
22
23
    /**
24
     * A list of Front Matter values that should not be returned directly from the $frontMatter array. Values listed
25
     * here have dedicated functions that handle those Front Matter values and the respective functions should be called
26
     * instead.
27
     *
28
     * @var string[]
29
     */
30
    protected $frontMatterBlacklist;
31
32
    /**
33
     * Set to true if the permalink has been sanitized
34
     *
35
     * @var bool
36
     */
37
    protected $permalinkEvaluated;
38
39
    /**
40
     * Set to true if the front matter has already been evaluated with variable interpolation
41
     *
42
     * @var bool
43
     */
44
    protected $frontMatterEvaluated;
45
46
    /**
47
     * An array containing the Yaml of the file
48
     *
49
     * @var array
50
     */
51
    protected $frontMatter;
52
53
    /**
54
     * Set to true if the body has already been parsed as markdown or any other format
55
     *
56
     * @var bool
57
     */
58
    protected $bodyContentEvaluated;
59
60
    /**
61
     * Only the body of the file, i.e. the content
62
     *
63
     * @var string
64
     */
65
    protected $bodyContent;
66
67
    /**
68
     * The extension of the file
69
     *
70
     * @var string
71
     */
72
    protected $extension;
73
74
    /**
75
     * The original file path to the ContentItem
76
     *
77
     * @var string
78
     */
79
    protected $filePath;
80
81
    /**
82
     * A filesystem object
83
     *
84
     * @var Filesystem
85
     */
86
    protected $fs;
87
88
    /**
89
     * ContentItem constructor.
90
     *
91
     * @param string $filePath The path to the file that will be parsed into a ContentItem
92
     *
93
     * @throws FileNotFoundException The given file path does not exist
94
     * @throws IOException           The file was not a valid ContentItem. This would meam there was no front matter or
95
     *                               no body
96
     */
97 28
    public function __construct ($filePath)
98
    {
99 28
        $this->frontMatterBlacklist = array('permalink');
100
101 28
        $this->filePath = $filePath;
102 28
        $this->fs       = new Filesystem();
103
104 28
        if (!$this->fs->exists($filePath))
105
        {
106 1
            throw new FileNotFoundException("The following file could not be found: ${filePath}");
107
        }
108
109
        $this->extension = strtolower($this->fs->getExtension($filePath));
110
111
        $this->refreshFileContent();
112
    }
113
114
    /**
115
     * The magic getter returns values from the front matter in order to make these values accessible to Twig templates
116
     * in a simple fashion
117
     *
118
     * @param  string $name The key in the front matter
119
     *
120
     * @return mixed|null
121
     */
122
    public function __get ($name)
123
    {
124 3
        return (array_key_exists($name, $this->frontMatter) ? $this->frontMatter[$name] : null);
125
    }
126
127
    /**
128
     * The magic getter returns true if the value exists in the Front Matter. This is used in conjunction with the __get
129
     * function
130
     *
131
     * @param  string $name The name of the Front Matter value being looked for
132
     *
133
     * @return bool
134
     */
135
    public function __isset ($name)
136
    {
137 1
        return (!in_array($name, $this->frontMatterBlacklist)) && array_key_exists($name, $this->frontMatter);
138
    }
139
140
    /**
141
     * Return the body of the Content Item
142
     *
143
     * @return string
144
     */
145
    abstract public function getContent ();
146
147
    /**
148
     * @param array|null $variables An array of YAML variables to use in evaluating the `$permalink` value
149
     */
150
    final public function evaluateFrontMatter ($variables = null)
151
    {
152 2
        if (!is_null($variables))
153
        {
154 2
            $this->frontMatter = array_merge($this->frontMatter, $variables);
155 2
            $this->handleSpecialFrontMatter();
156 2
            $this->evaluateYaml($this->frontMatter);
157
        }
158 2
    }
159
160
    /**
161
     * Get the Front Matter of a ContentItem as an array
162
     *
163
     * @param  bool $evaluateYaml When set to true, the YAML will be evaluated for variables
164
     *
165
     * @return array
166
     */
167
    final public function getFrontMatter ($evaluateYaml = true)
168
    {
169 6
        if ($this->frontMatter === null)
170
        {
171 1
            $this->frontMatter = array();
172
        }
173 5
        else if (!$this->frontMatterEvaluated && $evaluateYaml && !empty($evaluateYaml))
174
        {
175 5
            $this->evaluateYaml($this->frontMatter);
176 4
            $this->frontMatterEvaluated = true;
177
        }
178
179 5
        return $this->frontMatter;
180
    }
181
182
    /**
183
     * Get the permalink of this Content Item
184
     *
185
     * @return string
186
     */
187
    final public function getPermalink ()
188
    {
189 7
        if ($this->permalinkEvaluated)
190
        {
191
            return $this->frontMatter['permalink'];
192
        }
193
194 7
        $permalink = (is_array($this->frontMatter) && array_key_exists('permalink', $this->frontMatter)) ?
195 7
            $this->frontMatter['permalink'] : $this->getPathPermalink();
196
197 7
        $this->frontMatter['permalink'] = $this->sanitizePermalink($permalink);
198 7
        $this->permalinkEvaluated = true;
199
200 7
        return $this->frontMatter['permalink'];
201
    }
202
203
    /**
204
     * Get the destination of where this Content Item would be written to when the website is compiled
205
     *
206
     * @return string
207
     */
208
    final public function getTargetFile ()
209
    {
210 5
        $permalink  = $this->getPermalink();
211 5
        $extension  = $this->fs->getExtension($permalink);
212
213 5
        if (empty($extension))
214
        {
215 1
            $permalink = rtrim($permalink, '/') . '/index.html';
216
        }
217
218 5
        return ltrim($permalink, '/');
219
    }
220
221
    /**
222
     * Get the name of the item, which is just the file name without the extension
223
     *
224
     * @return string
225
     */
226
    final public function getName ()
227
    {
228 4
        return $this->fs->getBaseName($this->filePath);
229
    }
230
231
    /**
232
     * Get the original file path
233
     *
234
     * @return string
235
     */
236
    final public function getFilePath ()
237
    {
238 1
        return $this->filePath;
239
    }
240
241
    /**
242
     * Get the relative path to this file relative to the root of the Stakx website
243
     *
244
     * @return string
245
     */
246
    final public function getRelativeFilePath ()
247
    {
248 4
        return $this->fs->getRelativePath($this->filePath);
249
    }
250
251
    /**
252
     * Read the file, and parse its contents
253
     */
254
    final public function refreshFileContent ()
255
    {
256 27
        $rawFileContents = file_get_contents($this->filePath);
257
258 27
        $frontMatter = array();
259 27
        preg_match('/---(.*?)---(.*)/s', $rawFileContents, $frontMatter);
260
261 27 View Code Duplication
        if (count($frontMatter) != 3)
1 ignored issue
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...
262
        {
263 1
            throw new IOException(sprintf("'%s' is not a valid ContentItem",
264 1
                    $this->fs->getFileName($this->filePath))
265
            );
266
        }
267
268 26 View Code Duplication
        if (empty(trim($frontMatter[2])))
1 ignored issue
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...
269
        {
270 1
            throw new IOException(sprintf('A ContentItem (%s) must have a body to render',
271 1
                    $this->fs->getFileName($this->filePath))
272
            );
273
        }
274
275 25
        $this->frontMatter = Yaml::parse($frontMatter[1]);
0 ignored issues
show
Documentation Bug introduced by
It seems like \Symfony\Component\Yaml\...:parse($frontMatter[1]) 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...
276 24
        $this->bodyContent = trim($frontMatter[2]);
277
278 24
        $this->frontMatterEvaluated = false;
279 24
        $this->bodyContentEvaluated = false;
280 24
        $this->permalinkEvaluated = false;
281
282 24
        $this->handleSpecialFrontMatter();
283 24
        $this->findTwigDataDependencies('collections');
284 24
        $this->findTwigDataDependencies('data');
285 24
    }
286
287
    /**
288
     * Check whether this object has a reference to a collection or data item
289
     *
290
     * @param  string $namespace 'collections' or 'data'
291
     * @param  string $needle
292
     *
293
     * @return bool
294
     */
295
    final public function hasTwigDependency ($namespace, $needle)
296
    {
297
        return (in_array($needle, $this->dataDependencies[$namespace]));
298
    }
299
300
    /**
301
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
302
     *
303
     * @param  array $yaml An array of data containing FrontMatter variables
304
     *
305
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
306
     */
307
    final protected function evaluateYaml (&$yaml)
308
    {
309 7
        foreach ($yaml as $key => $value)
310
        {
311 7
            if (is_array($yaml[$key]))
312
            {
313 1
                $this->evaluateYaml($yaml[$key]);
314
            }
315
            else
316
            {
317 7
                $yaml[$key] = $this->evaluateYamlVar($value, $this->frontMatter);
318
            }
319
        }
320 6
    }
321
322
    /**
323
     * Evaluate an string for FrontMatter variables and replace them with the corresponding values
324
     *
325
     * @param  string $string The string that will be evaluated
326
     * @param  array  $yaml   The existing front matter from which the variable values will be pulled from
327
     *
328
     * @return string The final string with variables evaluated
329
     *
330
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
331
     */
332
    private function evaluateYamlVar ($string, $yaml)
333
    {
334 7
        $variables = array();
335 7
        $varRegex  = '/(%[a-zA-Z]+)/';
336 7
        $output    = $string;
337
338 7
        preg_match_all($varRegex, $string, $variables);
339
340
        // Default behavior causes $variables[0] is the entire string that was matched. $variables[1] will be each
341
        // matching result individually.
342 7
        foreach ($variables[1] as $variable)
343
        {
344 6
            $yamlVar = substr($variable, 1); // Trim the '%' from the YAML variable name
345
346 6
            if (!array_key_exists($yamlVar, $yaml))
347
            {
348 1
                throw new YamlVariableUndefinedException("Yaml variable `$variable` is not defined");
349
            }
350
351 5
            $output = str_replace($variable, $yaml[$yamlVar], $output);
352
        }
353
354 6
        return $output;
355
    }
356
357
    /**
358
     * Handle special front matter values that need special treatment or have special meaning to a Content Item
359
     */
360
    private function handleSpecialFrontMatter ()
361
    {
362 24
        if (isset($this->frontMatter['date']))
363
        {
364
            try
365
            {
366
                // Coming from a string variable
367 3
                $itemDate = new \DateTime($this->frontMatter['date']);
368
            }
369 1
            catch (\Exception $e)
370
            {
371
                // YAML has parsed them to Epoch time
372 1
                $itemDate = \DateTime::createFromFormat('U', $this->frontMatter['date']);
373
            }
374
375 3
            if (!$itemDate === false)
376
            {
377 2
                $this->frontMatter['year']  = $itemDate->format('Y');
378 2
                $this->frontMatter['month'] = $itemDate->format('m');
379 2
                $this->frontMatter['day']   = $itemDate->format('d');
380
            }
381
        }
382 24
    }
383
384
    /**
385
     * Get all of the references to either DataItems or ContentItems inside of given string
386
     *
387
     * @param string $filter 'collections' or 'data'
388
     */
389
    private function findTwigDataDependencies ($filter)
390
    {
391 24
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
392 24
        $results = array();
393
394 24
        preg_match_all($regex, $this->bodyContent, $results);
395
396 24
        $this->dataDependencies[$filter] = array_unique($results[1]);
397 24
    }
398
399
    /**
400
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
401
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
402
     *
403
     * @return string
404
     */
405
    private function getPathPermalink ()
406
    {
407
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
408 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->filePath);
409 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
410
411
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
412 3
        $folders = explode('/', $cleanPath);
413
414 3
        if (substr($folders[0], 0, 1) === '_')
415
        {
416 1
            array_shift($folders);
417
        }
418
419 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
420
421 3
        return $cleanPath;
422
    }
423
424
    /**
425
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens
426
     *
427
     * @param  string $permalink A permalink
428
     *
429
     * @return string $permalink The sanitized permalink
430
     */
431
    private function sanitizePermalink ($permalink)
432
    {
433
        // Remove multiple '/' together
434 7
        $permalink = preg_replace('/\/+/', '/', $permalink);
435
436
        // Replace all spaces with hyphens
437 7
        $permalink = str_replace(' ', '-', $permalink);
438
439
        // Remove all disallowed characters
440 7
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\.]/', '', $permalink);
441
442
        // Handle unnecessary extensions
443 7
        $extensionsToStrip = array('twig');
444
445 7
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
446
        {
447 3
            $permalink = $this->fs->removeExtension($permalink);
448
        }
449
450
        // Remove a special './' combination from the beginning of a path
451 7
        if (substr($permalink, 0, 2) === './')
452
        {
453 1
            $permalink = substr($permalink, 2);
454
        }
455
456
        // Convert permalinks to lower case
457 7
        $permalink = mb_strtolower($permalink, 'UTF-8');
458
459 7
        return $permalink;
460
    }
461
}