Passed
Pull Request — master (#2214)
by Arnaud
04:47
created

Page::__construct()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 4.0023

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 17
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 26
ccs 18
cts 19
cp 0.9474
crap 4.0023
rs 9.7
1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Collection\Page;
15
16
use Cecil\Collection\Item;
17
use Cecil\Exception\RuntimeException;
18
use Cecil\Util;
19
use Cocur\Slugify\Slugify;
20
use Symfony\Component\Finder\SplFileInfo;
21
22
/**
23
 * Page class.
24
 *
25
 * Represents a page in the collection, which can be created from a file or be virtual.
26
 * Provides methods to manage page properties, variables, and rendering.
27
 */
28
class Page extends Item
29
{
30
    public const SLUGIFY_PATTERN = '/(^\/|[^._a-z0-9\/]|-)+/'; // should be '/^\/|[^_a-z0-9\/]+/'
31
32
    /** @var bool True if page is not created from a file. */
33
    protected $virtual;
34
35
    /** @var SplFileInfo */
36
    protected $file;
37
38
    /** @var Type Type */
39
    protected $type;
40
41
    /** @var string */
42
    protected $folder;
43
44
    /** @var string */
45
    protected $slug;
46
47
    /** @var string path = folder + slug. */
48
    protected $path;
49
50
    /** @var string */
51
    protected $section;
52
53
    /** @var string */
54
    protected $frontmatter;
55
56
    /** @var array Front matter before conversion. */
57
    protected $fmVariables = [];
58
59
    /** @var string Body before conversion. */
60
    protected $body;
61
62
    /** @var string Body after conversion. */
63
    protected $html;
64
65
    /** @var array Output, by format */
66
    protected $rendered = [];
67
68
    /** @var Collection Subpages of a list page. */
69
    protected $subPages;
70
71
    /** @var array */
72
    protected $paginator = [];
73
74
    /** @var \Cecil\Collection\Taxonomy\Vocabulary Terms of a vocabulary. */
75
    protected $terms;
76
77
    /** @var Slugify */
78
    private static $slugifier;
79
80 1
    public function __construct(mixed $id)
81
    {
82 1
        if (!\is_string($id) && !$id instanceof SplFileInfo) {
83
            throw new RuntimeException('Create a page with a string ID or a SplFileInfo.');
84
        }
85
86
        // default properties
87 1
        $this->setVirtual(true);
88 1
        $this->setType(Type::PAGE->value);
89 1
        $this->setVariables([
90 1
            'title'            => 'Page Title',
91 1
            'date'             => new \DateTime(),
92 1
            'updated'          => new \DateTime(),
93 1
            'weight'           => null,
94 1
            'filepath'         => null,
95 1
            'published'        => true,
96 1
            'content_template' => 'page.content.twig',
97 1
        ]);
98
99 1
        if ($id instanceof SplFileInfo) {
100 1
            $file = $id;
101 1
            $this->setFile($file);
102 1
            $id = self::createIdFromFile($file);
103
        }
104
105 1
        parent::__construct($id);
106
    }
107
108
    /**
109
     * {@inheritdoc}
110
     */
111 1
    public function setId(string $id): self
112
    {
113 1
        return parent::setId($id);
114
    }
115
116
    /**
117
     * toString magic method to prevent Twig get_attribute fatal error.
118
     *
119
     * @return string
120
     */
121 1
    public function __toString()
122
    {
123 1
        return $this->getId();
124
    }
125
126
    /**
127
     * Turns a path (string) into a slug (URI).
128
     */
129 1
    public static function slugify(string $path): string
130
    {
131 1
        if (!self::$slugifier instanceof Slugify) {
132 1
            self::$slugifier = Slugify::create(['regexp' => self::SLUGIFY_PATTERN]);
133
        }
134
135 1
        return self::$slugifier->slugify($path);
136
    }
137
138
    /**
139
     * Returns the ID of a page without language.
140
     */
141 1
    public function getIdWithoutLang(): string
142
    {
143 1
        $langPrefix = $this->getVariable('language') . '/';
144 1
        if ($this->hasVariable('language') && Util\Str::startsWith($this->getId(), $langPrefix)) {
145 1
            return substr($this->getId(), \strlen($langPrefix));
146
        }
147
148 1
        return $this->getId();
149
    }
150
151
    /**
152
     * Set file.
153
     */
154 1
    public function setFile(SplFileInfo $file): self
155
    {
156 1
        $this->file = $file;
157 1
        $this->setVirtual(false);
158
159
        /*
160
         * File path components
161
         */
162 1
        $fileRelativePath = str_replace(DIRECTORY_SEPARATOR, '/', $this->file->getRelativePath());
163 1
        $fileExtension = $this->file->getExtension();
164 1
        $fileName = $this->file->getBasename('.' . $fileExtension);
165
        // renames "README" to "index"
166 1
        $fileName = strtolower($fileName) == 'readme' ? 'index' : $fileName;
167
        // case of "index" = home page
168 1
        if (empty($this->file->getRelativePath()) && PrefixSuffix::sub($fileName) == 'index') {
169 1
            $this->setType(Type::HOMEPAGE->value);
170
        }
171
        /*
172
         * Set page properties and variables
173
         */
174 1
        $this->setFolder($fileRelativePath);
175 1
        $this->setSlug($fileName);
176 1
        $this->setPath($this->getFolder() . '/' . $this->getSlug());
177 1
        $this->setVariables([
178 1
            'title'    => PrefixSuffix::sub($fileName),
179 1
            'date'     => (new \DateTime())->setTimestamp($this->file->getMTime()),
180 1
            'updated'  => (new \DateTime())->setTimestamp($this->file->getMTime()),
181 1
            'filepath' => $this->file->getRelativePathname(),
182 1
        ]);
183
        /*
184
         * Set specific variables
185
         */
186
        // is file has a prefix?
187 1
        if (PrefixSuffix::hasPrefix($fileName)) {
188 1
            $prefix = PrefixSuffix::getPrefix($fileName);
189 1
            if ($prefix !== null) {
190
                // prefix is an integer: used for sorting
191 1
                if (is_numeric($prefix)) {
192 1
                    $this->setVariable('weight', (int) $prefix);
193
                }
194
                // prefix is a valid date?
195 1
                if (Util\Date::isValid($prefix)) {
196 1
                    $this->setVariable('date', (string) $prefix);
197
                }
198
            }
199
        }
200
        // is file has a language suffix?
201 1
        if (PrefixSuffix::hasSuffix($fileName)) {
202 1
            $this->setVariable('language', PrefixSuffix::getSuffix($fileName));
203
        }
204
        // set reference between page's translations, even if it exist in only one language
205 1
        $this->setVariable('langref', $this->getPath());
206
207 1
        return $this;
208
    }
209
210
    /**
211
     * Returns file name, with extension.
212
     */
213
    public function getFileName(): ?string
214
    {
215
        if ($this->file === null) {
216
            return null;
217
        }
218
219
        return $this->file->getBasename();
220
    }
221
222
    /**
223
     * Returns file real path.
224
     */
225 1
    public function getFilePath(): ?string
226
    {
227 1
        if ($this->file === null) {
228
            return null;
229
        }
230
231 1
        return $this->file->getRealPath() === false ? null : $this->file->getRealPath();
232
    }
233
234
    /**
235
     * Parse file content.
236
     */
237 1
    public function parse(): self
238
    {
239 1
        $parser = new Parser($this->file);
240 1
        $parsed = $parser->parse();
241 1
        $this->frontmatter = $parsed->getFrontmatter();
242 1
        $this->body = $parsed->getBody();
243
244 1
        return $this;
245
    }
246
247
    /**
248
     * Get front matter.
249
     */
250 1
    public function getFrontmatter(): ?string
251
    {
252 1
        return $this->frontmatter;
253
    }
254
255
    /**
256
     * Get body as raw.
257
     */
258 1
    public function getBody(): ?string
259
    {
260 1
        return $this->body;
261
    }
262
263
    /**
264
     * Set virtual status.
265
     */
266 1
    public function setVirtual(bool $virtual): self
267
    {
268 1
        $this->virtual = $virtual;
269
270 1
        return $this;
271
    }
272
273
    /**
274
     * Is current page is virtual?
275
     */
276 1
    public function isVirtual(): bool
277
    {
278 1
        return $this->virtual;
279
    }
280
281
    /**
282
     * Set page type.
283
     */
284 1
    public function setType(string $type): self
285
    {
286 1
        $this->type = Type::from($type);
287
288 1
        return $this;
289
    }
290
291
    /**
292
     * Get page type.
293
     */
294 1
    public function getType(): string
295
    {
296 1
        return $this->type->value;
297
    }
298
299
    /**
300
     * Set path without slug.
301
     */
302 1
    public function setFolder(string $folder): self
303
    {
304 1
        $this->folder = self::slugify($folder);
305
306 1
        return $this;
307
    }
308
309
    /**
310
     * Get path without slug.
311
     */
312 1
    public function getFolder(): ?string
313
    {
314 1
        return $this->folder;
315
    }
316
317
    /**
318
     * Set slug.
319
     */
320 1
    public function setSlug(string $slug): self
321
    {
322 1
        if (!$this->slug) {
323 1
            $slug = self::slugify(PrefixSuffix::sub($slug));
324
        }
325
        // force slug and update path
326 1
        if ($this->slug && $this->slug != $slug) {
327 1
            $this->setPath($this->getFolder() . '/' . $slug);
328
        }
329 1
        $this->slug = $slug;
330
331 1
        return $this;
332
    }
333
334
    /**
335
     * Get slug.
336
     */
337 1
    public function getSlug(): string
338
    {
339 1
        return $this->slug;
340
    }
341
342
    /**
343
     * Set path.
344
     */
345 1
    public function setPath(string $path): self
346
    {
347 1
        $path = trim($path, '/');
348
349
        // case of homepage
350 1
        if ($path == 'index') {
351 1
            $this->path = '';
352
353 1
            return $this;
354
        }
355
356
        // case of custom sections' index (ie: section/index.md -> section)
357 1
        if (substr($path, -6) == '/index') {
358 1
            $path = substr($path, 0, \strlen($path) - 6);
359
        }
360 1
        $this->path = $path;
361
362 1
        $lastslash = strrpos($this->path, '/');
363
364
        // case of root/top-level pages
365 1
        if ($lastslash === false) {
366 1
            $this->slug = $this->path;
367
368 1
            return $this;
369
        }
370
371
        // case of sections' pages: set section
372 1
        if (!$this->virtual && $this->getSection() === null) {
373 1
            $this->section = explode('/', $this->path)[0];
374
        }
375
        // set/update folder and slug
376 1
        $this->folder = substr($this->path, 0, $lastslash);
377 1
        $this->slug = substr($this->path, -(\strlen($this->path) - $lastslash - 1));
378
379 1
        return $this;
380
    }
381
382
    /**
383
     * Get path.
384
     */
385 1
    public function getPath(): ?string
386
    {
387 1
        return $this->path;
388
    }
389
390
    /**
391
     * @see getPath()
392
     */
393
    public function getPathname(): ?string
394
    {
395
        return $this->getPath();
396
    }
397
398
    /**
399
     * Set section.
400
     */
401 1
    public function setSection(string $section): self
402
    {
403 1
        $this->section = $section;
404
405 1
        return $this;
406
    }
407
408
    /**
409
     * Get section.
410
     */
411 1
    public function getSection(): ?string
412
    {
413 1
        return !empty($this->section) ? $this->section : null;
414
    }
415
416
    /**
417
     * Unset section.
418
     */
419
    public function unSection(): self
420
    {
421
        $this->section = null;
422
423
        return $this;
424
    }
425
426
    /**
427
     * Set body as HTML.
428
     */
429 1
    public function setBodyHtml(string $html): self
430
    {
431 1
        $this->html = $html;
432
433 1
        return $this;
434
    }
435
436
    /**
437
     * Get body as HTML.
438
     */
439 1
    public function getBodyHtml(): ?string
440
    {
441 1
        return $this->html;
442
    }
443
444
    /**
445
     * @see getBodyHtml()
446
     */
447 1
    public function getContent(): ?string
448
    {
449 1
        return $this->getBodyHtml();
450
    }
451
452
    /**
453
     * Add rendered.
454
     */
455 1
    public function addRendered(array $rendered): self
456
    {
457 1
        $this->rendered += $rendered;
458
459 1
        return $this;
460
    }
461
462
    /**
463
     * Get rendered.
464
     */
465 1
    public function getRendered(): array
466
    {
467 1
        return $this->rendered;
468
    }
469
470
    /**
471
     * Set Subpages.
472
     */
473 1
    public function setPages(\Cecil\Collection\Page\Collection $subPages): self
474
    {
475 1
        $this->subPages = $subPages;
476
477 1
        return $this;
478
    }
479
480
    /**
481
     * Get Subpages.
482
     */
483 1
    public function getPages(): ?\Cecil\Collection\Page\Collection
484
    {
485 1
        return $this->subPages;
486
    }
487
488
    /**
489
     * Set paginator.
490
     */
491 1
    public function setPaginator(array $paginator): self
492
    {
493 1
        $this->paginator = $paginator;
494
495 1
        return $this;
496
    }
497
498
    /**
499
     * Get paginator.
500
     */
501 1
    public function getPaginator(): array
502
    {
503 1
        return $this->paginator;
504
    }
505
506
    /**
507
     * Paginator backward compatibility.
508
     */
509
    public function getPagination(): array
510
    {
511
        return $this->getPaginator();
512
    }
513
514
    /**
515
     * Set vocabulary terms.
516
     */
517 1
    public function setTerms(\Cecil\Collection\Taxonomy\Vocabulary $terms): self
518
    {
519 1
        $this->terms = $terms;
520
521 1
        return $this;
522
    }
523
524
    /**
525
     * Get vocabulary terms.
526
     */
527 1
    public function getTerms(): \Cecil\Collection\Taxonomy\Vocabulary
528
    {
529 1
        return $this->terms;
530
    }
531
532
    /*
533
     * Helpers to set and get variables.
534
     */
535
536
    /**
537
     * Set an array as variables.
538
     *
539
     * @throws RuntimeException
540
     */
541 1
    public function setVariables(array $variables): self
542
    {
543 1
        foreach ($variables as $key => $value) {
544 1
            $this->setVariable($key, $value);
545
        }
546
547 1
        return $this;
548
    }
549
550
    /**
551
     * Get all variables.
552
     */
553 1
    public function getVariables(): array
554
    {
555 1
        return $this->properties;
556
    }
557
558
    /**
559
     * Set a variable.
560
     *
561
     * @param string $name  Name of the variable
562
     * @param mixed  $value Value of the variable
563
     *
564
     * @throws RuntimeException
565
     */
566 1
    public function setVariable(string $name, $value): self
567
    {
568 1
        $this->filterBool($value);
569
        switch ($name) {
570 1
            case 'date':
571 1
            case 'updated':
572 1
            case 'lastmod':
573
                try {
574 1
                    $date = Util\Date::toDatetime($value);
575
                } catch (\Exception) {
576
                    throw new \Exception(\sprintf('The value of "%s" is not a valid date: "%s".', $name, var_export($value, true)));
577
                }
578 1
                $this->offsetSet($name == 'lastmod' ? 'updated' : $name, $date);
579 1
                break;
580
581 1
            case 'schedule':
582
                /*
583
                 * publish: 2012-10-08
584
                 * expiry: 2012-10-09
585
                 */
586 1
                $this->offsetSet('published', false);
587 1
                if (\is_array($value)) {
588 1
                    if (\array_key_exists('publish', $value) && Util\Date::toDatetime($value['publish']) <= Util\Date::toDatetime('now')) {
589 1
                        $this->offsetSet('published', true);
590
                    }
591 1
                    if (\array_key_exists('expiry', $value) && Util\Date::toDatetime($value['expiry']) >= Util\Date::toDatetime('now')) {
592
                        $this->offsetSet('published', true);
593
                    }
594
                }
595 1
                break;
596 1
            case 'draft':
597
                // draft: true = published: false
598 1
                if ($value === true) {
599 1
                    $this->offsetSet('published', false);
600
                }
601 1
                break;
602 1
            case 'path':
603 1
            case 'slug':
604 1
                $slugify = self::slugify((string) $value);
605 1
                if ($value != $slugify) {
606
                    throw new RuntimeException(\sprintf('"%s" variable should be "%s" (not "%s") in "%s".', $name, $slugify, (string) $value, $this->getId()));
607
                }
608 1
                $method = 'set' . ucfirst($name);
609 1
                $this->$method($value);
610 1
                break;
611
            default:
612 1
                $this->offsetSet($name, $value);
613
        }
614
615 1
        return $this;
616
    }
617
618
    /**
619
     * Is variable exists?
620
     *
621
     * @param string $name Name of the variable
622
     */
623 1
    public function hasVariable(string $name): bool
624
    {
625 1
        return $this->offsetExists($name);
626
    }
627
628
    /**
629
     * Get a variable.
630
     *
631
     * @param string     $name    Name of the variable
632
     * @param mixed|null $default Default value
633
     *
634
     * @return mixed|null
635
     */
636 1
    public function getVariable(string $name, $default = null)
637
    {
638 1
        if ($this->offsetExists($name)) {
639 1
            return $this->offsetGet($name);
640
        }
641
642 1
        return $default;
643
    }
644
645
    /**
646
     * Unset a variable.
647
     *
648
     * @param string $name Name of the variable
649
     */
650 1
    public function unVariable(string $name): self
651
    {
652 1
        if ($this->offsetExists($name)) {
653 1
            $this->offsetUnset($name);
654
        }
655
656 1
        return $this;
657
    }
658
659
    /**
660
     * Set front matter (only) variables.
661
     */
662 1
    public function setFmVariables(array $variables): self
663
    {
664 1
        $this->fmVariables = $variables;
665
666 1
        return $this;
667
    }
668
669
    /**
670
     * Get front matter variables.
671
     */
672 1
    public function getFmVariables(): array
673
    {
674 1
        return $this->fmVariables;
675
    }
676
677
    /**
678
     * Creates a page ID from a file (based on path).
679
     */
680 1
    private static function createIdFromFile(SplFileInfo $file): string
681
    {
682 1
        $relativePath = self::slugify(str_replace(DIRECTORY_SEPARATOR, '/', $file->getRelativePath()));
683 1
        $basename = self::slugify(PrefixSuffix::subPrefix($file->getBasename('.' . $file->getExtension())));
684
        // if file is "README.md", ID is "index"
685 1
        $basename = strtolower($basename) == 'readme' ? 'index' : $basename;
686
        // if file is section's index: "section/index.md", ID is "section"
687 1
        if (!empty($relativePath) && PrefixSuffix::sub($basename) == 'index') {
688
            // case of a localized section's index: "section/index.fr.md", ID is "fr/section"
689 1
            if (PrefixSuffix::hasSuffix($basename)) {
690 1
                return PrefixSuffix::getSuffix($basename) . '/' . $relativePath;
691
            }
692
693 1
            return $relativePath;
694
        }
695
        // localized page
696 1
        if (PrefixSuffix::hasSuffix($basename)) {
697 1
            return trim(Util::joinPath(PrefixSuffix::getSuffix($basename), $relativePath, PrefixSuffix::sub($basename)), '/');
698
        }
699
700 1
        return trim(Util::joinPath($relativePath, $basename), '/');
701
    }
702
703
    /**
704
     * Cast "boolean" string (or array of strings) to boolean.
705
     *
706
     * @param mixed $value Value to filter
707
     *
708
     * @return bool|mixed
709
     *
710
     * @see strToBool()
711
     */
712 1
    private function filterBool(&$value)
713
    {
714 1
        \Cecil\Util\Str::strToBool($value);
715 1
        if (\is_array($value)) {
716 1
            array_walk_recursive($value, '\Cecil\Util\Str::strToBool');
717
        }
718
    }
719
}
720