Issues (20)

Branch: nested-sections-2025

src/Collection/Page/Page.php (1 issue)

Labels
Severity
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
        // set default properties and variables
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 file, create ID from its pathname
100 1
        if ($id instanceof SplFileInfo) {
101 1
            $file = $id;
102 1
            $this->setFile($file);
103
            $id = self::createIdFromFile($file);
104
        }
105 1
106
        parent::__construct($id);
107
    }
108
109
    /**
110
     * {@inheritdoc}
111 1
     */
112
    public function setId(string $id): self
113 1
    {
114
        return parent::setId($id);
115
    }
116
117
    /**
118
     * toString magic method to prevent Twig get_attribute fatal error.
119
     *
120
     * @return string
121 1
     */
122
    public function __toString()
123 1
    {
124
        return $this->getId();
125
    }
126
127
    /**
128
     * Turns a path (string) into a slug (URI).
129 1
     */
130
    public static function slugify(string $path): string
131 1
    {
132 1
        if (!self::$slugifier instanceof Slugify) {
133
            self::$slugifier = Slugify::create(['regexp' => self::SLUGIFY_PATTERN]);
134
        }
135 1
136
        return self::$slugifier->slugify($path);
137
    }
138
139
    /**
140
     * Returns the ID of a page without language.
141 1
     */
142
    public function getIdWithoutLang(): string
143 1
    {
144 1
        $langPrefix = $this->getVariable('language') . '/';
145 1
        if ($this->hasVariable('language') && Util\Str::startsWith($this->getId(), $langPrefix)) {
146
            return substr($this->getId(), \strlen($langPrefix));
147
        }
148 1
149
        return $this->getId();
150
    }
151
152
    /**
153
     * Set file.
154 1
     */
155
    public function setFile(SplFileInfo $file): self
156 1
    {
157
        $this->file = $file;
158 1
159 1
        $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $this->file->getRelativePath());
160
        $filename = $this->file->getFilenameWithoutExtension();
161 1
        // renames "README" to "index"
162
        $filename = strtolower($filename) == 'readme' ? 'index' : $filename;
163
164 1
        // set properties
165 1
        $this->setVirtual(false);
166 1
        $this->setFolder($relativePath);
167 1
        $this->setSlug($filename);
168
        $this->setPath($this->getFolder() . '/' . $this->getSlug());
169 1
        // if "index" : type = section or homepage
170 1
        if (PrefixSuffix::sub($filename) == 'index') {
171
            // section by default
172
            $this->setType(Type::SECTION->value);
173 1
            // homepage
174 1
            if (empty($this->file->getRelativePath())) {
175 1
                $this->setType(Type::HOMEPAGE->value);
176 1
            }
177 1
        }
178 1
        // set variables
179
        $this->setVariables([
180 1
            'title'    => PrefixSuffix::sub($filename),
181 1
            'date'     => (new \DateTime())->setTimestamp($this->file->getMTime()),
182 1
            'updated'  => (new \DateTime())->setTimestamp($this->file->getMTime()),
183
            'filepath' => $this->file->getRelativePathname(),
184 1
        ]);
185 1
        // if prefix : set weight or date
186
        if (PrefixSuffix::hasPrefix($filename)) {
187
            $prefix = PrefixSuffix::getPrefix($filename);
188 1
            if ($prefix !== null) {
189 1
                // prefix is an integer: weight (used for sorting)
190
                if (is_numeric($prefix)) {
191
                    $this->setVariable('weight', (int) $prefix);
192
                }
193
                // prefix is a valid date?
194 1
                if (Util\Date::isValid($prefix)) {
195 1
                    $this->setVariable('date', (string) $prefix);
196
                }
197
            }
198 1
        }
199
        // if suffix : set language
200 1
        if (PrefixSuffix::hasSuffix($filename)) {
201
            $this->setVariable('language', PrefixSuffix::getSuffix($filename));
202
        }
203
        // set reference between page's translations (even if it exist in only one language)
204
        $this->setVariable('langref', $this->getPath());
205
206
        return $this;
207
    }
208
209
    /**
210
     * Returns file name, with extension.
211
     */
212
    public function getFileName(): ?string
213
    {
214
        if ($this->file === null) {
215
            return null;
216
        }
217
218 1
        return $this->file->getBasename();
219
    }
220 1
221
    /**
222
     * Returns file real path.
223
     */
224 1
    public function getFilePath(): ?string
225
    {
226
        if ($this->file === null) {
227
            return null;
228
        }
229
230 1
        return $this->file->getRealPath() === false ? null : $this->file->getRealPath();
231
    }
232 1
233 1
    /**
234 1
     * Parse file content.
235 1
     */
236
    public function parse(): self
237 1
    {
238
        $parser = new Parser($this->file);
239
        $parsed = $parser->parse();
240
        $this->frontmatter = $parsed->getFrontmatter();
241
        $this->body = $parsed->getBody();
242
243 1
        return $this;
244
    }
245 1
246
    /**
247
     * Get front matter.
248
     */
249
    public function getFrontmatter(): ?string
250
    {
251 1
        return $this->frontmatter;
252
    }
253 1
254
    /**
255
     * Get body as raw.
256
     */
257
    public function getBody(): ?string
258
    {
259 1
        return $this->body;
260
    }
261 1
262
    /**
263 1
     * Set virtual status.
264
     */
265
    public function setVirtual(bool $virtual): self
266
    {
267
        $this->virtual = $virtual;
268
269 1
        return $this;
270
    }
271 1
272
    /**
273
     * Is current page is virtual?
274
     */
275
    public function isVirtual(): bool
276
    {
277 1
        return $this->virtual;
278
    }
279 1
280
    /**
281 1
     * Set page type.
282
     */
283
    public function setType(string $type): self
284
    {
285
        $this->type = Type::from($type);
286
287 1
        return $this;
288
    }
289 1
290
    /**
291
     * Get page type.
292
     */
293
    public function getType(): string
294
    {
295 1
        return $this->type->value;
296
    }
297 1
298
    /**
299 1
     * Set path without slug.
300
     */
301
    public function setFolder(string $folder): self
302
    {
303
        $this->folder = self::slugify($folder);
304
305 1
        return $this;
306
    }
307 1
308
    /**
309
     * Get path without slug.
310
     */
311
    public function getFolder(): ?string
312
    {
313 1
        return $this->folder;
314
    }
315 1
316 1
    /**
317
     * Set slug.
318
     */
319 1
    public function setSlug(string $slug): self
320 1
    {
321
        if (!$this->slug) {
322 1
            $slug = self::slugify(PrefixSuffix::sub($slug));
323
        }
324 1
        // force slug and update path
325
        if ($this->slug && $this->slug != $slug) {
326
            $this->setPath($this->getFolder() . '/' . $slug);
327
        }
328
        $this->slug = $slug;
329
330 1
        return $this;
331
    }
332 1
333
    /**
334
     * Get slug.
335
     */
336
    public function getSlug(): string
337
    {
338 1
        return $this->slug;
339
    }
340 1
341
    /**
342
     * Set path.
343 1
     */
344 1
    public function setPath(string $path): self
345
    {
346 1
        $path = trim($path, '/');
347
348
        // homepage : path is empty
349
        if ($path == 'index') {
350 1
            $this->path = '';
351 1
352
            return $this;
353 1
        }
354
355 1
        // section/index : path = section
356
        if (substr($path, -6) == '/index') {
357
            $path = substr($path, 0, \strlen($path) - 6);
358 1
        }
359 1
        $this->path = $path;
360
361 1
        $lastslash = strrpos($this->path, '/');
362
363
        // top-level pages : slug = path
364
        if ($lastslash === false) {
365 1
            $this->slug = $this->path;
366 1
367
            return $this;
368
        }
369 1
370 1
        // set section
371
        if (!$this->virtual && $this->getSection() === null) {
372 1
            $this->section = explode('/', $this->path)[0];
373
        }
374
        // set/update folder and slug
375
        $this->folder = substr($this->path, 0, $lastslash);
376
        $this->slug = substr($this->path, -(\strlen($this->path) - $lastslash - 1));
377
378 1
        return $this;
379
    }
380 1
381
    /**
382
     * Get path.
383
     */
384
    public function getPath(): ?string
385
    {
386
        return $this->path;
387
    }
388
389
    /**
390
     * @see getPath()
391
     */
392
    public function getPathname(): ?string
393
    {
394 1
        return $this->getPath();
395
    }
396 1
397
    /**
398 1
     * Set section.
399
     */
400
    public function setSection(string $section): self
401
    {
402
        $this->section = $section;
403
404 1
        return $this;
405
    }
406 1
407
    /**
408
     * Get section.
409
     */
410
    public function getSection(): ?string
411
    {
412
        return !empty($this->section) ? $this->section : null;
413
    }
414
415
    /**
416
     * Unset section.
417
     */
418
    public function unSection(): self
419
    {
420
        $this->section = null;
421
422 1
        return $this;
423
    }
424 1
425
    /**
426 1
     * Set body as HTML.
427
     */
428
    public function setBodyHtml(string $html): self
429
    {
430
        $this->html = $html;
431
432 1
        return $this;
433
    }
434 1
435
    /**
436
     * Get body as HTML.
437
     */
438
    public function getBodyHtml(): ?string
439
    {
440 1
        return $this->html;
441
    }
442 1
443
    /**
444
     * @see getBodyHtml()
445
     */
446
    public function getContent(): ?string
447
    {
448 1
        return $this->getBodyHtml();
449
    }
450 1
451
    /**
452 1
     * Add rendered.
453
     */
454
    public function addRendered(array $rendered): self
455
    {
456
        $this->rendered += $rendered;
457
458 1
        return $this;
459
    }
460 1
461
    /**
462
     * Get rendered.
463
     */
464
    public function getRendered(): array
465
    {
466
        return $this->rendered;
467
    }
468 1
469
    /**
470 1
     * Set Subpages.
471
     *
472 1
     * @todo should be removed
473
     */
474
    public function setPages(\Cecil\Collection\Page\Collection $subPages): self
475
    {
476
        $this->subPages = $subPages;
477
478
        return $this;
479
    }
480 1
481
    /**
482 1
     * Get Subpages.
483
     *
484
     * @todo should returns pages with "parent" contains current section page
485
     */
486
    public function getPages(): ?\Cecil\Collection\Page\Collection
487
    {
488 1
        return $this->subPages;
489
    }
490 1
491
    /**
492 1
     * Set paginator.
493
     */
494
    public function setPaginator(array $paginator): self
495
    {
496
        $this->paginator = $paginator;
497
498 1
        return $this;
499
    }
500 1
501
    /**
502
     * Get paginator.
503
     */
504
    public function getPaginator(): array
505
    {
506
        return $this->paginator;
507
    }
508
509
    /**
510
     * Paginator backward compatibility.
511
     */
512
    public function getPagination(): array
513
    {
514 1
        return $this->getPaginator();
515
    }
516 1
517
    /**
518 1
     * Set vocabulary terms.
519
     */
520
    public function setTerms(\Cecil\Collection\Taxonomy\Vocabulary $terms): self
521
    {
522
        $this->terms = $terms;
523
524 1
        return $this;
525
    }
526 1
527
    /**
528
     * Get vocabulary terms.
529
     */
530
    public function getTerms(): \Cecil\Collection\Taxonomy\Vocabulary
531
    {
532
        return $this->terms;
533
    }
534
535
    /*
536
     * Helpers to set and get variables.
537
     */
538 1
539
    /**
540 1
     * Set an array as variables.
541 1
     *
542
     * @throws RuntimeException
543
     */
544 1
    public function setVariables(array $variables): self
545
    {
546
        foreach ($variables as $key => $value) {
547
            $this->setVariable($key, $value);
548
        }
549
550 1
        return $this;
551
    }
552 1
553
    /**
554
     * Get all variables.
555
     */
556
    public function getVariables(): array
557
    {
558
        return $this->properties;
559
    }
560
561
    /**
562
     * Set a variable.
563 1
     *
564
     * @param string $name  Name of the variable
565 1
     * @param mixed  $value Value of the variable
566
     *
567 1
     * @throws RuntimeException
568 1
     */
569 1
    public function setVariable(string $name, $value): self
570
    {
571 1
        $this->filterBool($value);
572
        switch ($name) {
573
            case 'date':
574
            case 'updated':
575 1
            case 'lastmod':
576 1
                try {
577
                    $date = Util\Date::toDatetime($value);
578 1
                } catch (\Exception) {
579
                    throw new \Exception(\sprintf('The value of "%s" is not a valid date: "%s".', $name, var_export($value, true)));
580
                }
581
                $this->offsetSet($name == 'lastmod' ? 'updated' : $name, $date);
582
                break;
583 1
584 1
            case 'schedule':
585 1
                /*
586 1
                 * publish: 2012-10-08
587
                 * expiry: 2012-10-09
588 1
                 */
589
                $this->offsetSet('published', false);
590
                if (\is_array($value)) {
591
                    if (\array_key_exists('publish', $value) && Util\Date::toDatetime($value['publish']) <= Util\Date::toDatetime('now')) {
592 1
                        $this->offsetSet('published', true);
593 1
                    }
594
                    if (\array_key_exists('expiry', $value) && Util\Date::toDatetime($value['expiry']) >= Util\Date::toDatetime('now')) {
595 1
                        $this->offsetSet('published', true);
596 1
                    }
597
                }
598 1
                break;
599 1
            case 'draft':
600 1
                // draft: true = published: false
601 1
                if ($value === true) {
602 1
                    $this->offsetSet('published', false);
603
                }
604
                break;
605 1
            case 'path':
606 1
            case 'slug':
607 1
                $slugify = self::slugify((string) $value);
608
                if ($value != $slugify) {
609 1
                    throw new RuntimeException(\sprintf('"%s" variable should be "%s" (not "%s") in "%s".', $name, $slugify, (string) $value, $this->getId()));
610
                }
611
                $method = 'set' . ucfirst($name);
612 1
                $this->$method($value);
613
                break;
614
            default:
615
                $this->offsetSet($name, $value);
616
        }
617
618
        return $this;
619
    }
620 1
621
    /**
622 1
     * Is variable exists?
623
     *
624
     * @param string $name Name of the variable
625
     */
626
    public function hasVariable(string $name): bool
627
    {
628
        return $this->offsetExists($name);
629
    }
630
631
    /**
632
     * Get a variable.
633 1
     *
634
     * @param string     $name    Name of the variable
635 1
     * @param mixed|null $default Default value
636 1
     *
637
     * @return mixed|null
638
     */
639 1
    public function getVariable(string $name, $default = null)
640
    {
641
        if ($this->offsetExists($name)) {
642
            return $this->offsetGet($name);
643
        }
644
645
        return $default;
646
    }
647 1
648
    /**
649 1
     * Unset a variable.
650 1
     *
651
     * @param string $name Name of the variable
652
     */
653 1
    public function unVariable(string $name): self
654
    {
655
        if ($this->offsetExists($name)) {
656
            $this->offsetUnset($name);
657
        }
658
659 1
        return $this;
660
    }
661 1
662
    /**
663 1
     * Set front matter (only) variables.
664
     */
665
    public function setFmVariables(array $variables): self
666
    {
667
        $this->fmVariables = $variables;
668
669 1
        return $this;
670
    }
671 1
672
    /**
673
     * Get front matter variables.
674
     */
675
    public function getFmVariables(): array
676
    {
677 1
        return $this->fmVariables;
678
    }
679 1
680 1
    /**
681
     * Creates a page ID from a file (based on path).
682 1
     */
683
    private static function createIdFromFile(SplFileInfo $file): string
684 1
    {
685
        $relativePath = self::slugify(str_replace(DIRECTORY_SEPARATOR, '/', $file->getRelativePath()));
686 1
        $filename = self::slugify(PrefixSuffix::subPrefix($file->getFilenameWithoutExtension()));
687 1
        // if file is "README.md", ID is "index"
688
        $filename = strtolower($filename) == 'readme' ? 'index' : $filename;
689
        // if file is section's index (ie: if file is "section/index.md", ID is "section")
690 1
        if (!empty($relativePath) && PrefixSuffix::sub($filename) == 'index') {
691
            // localized section (ie: if file is "section/index.fr.md", ID is "fr/section")
692
            if (PrefixSuffix::hasSuffix($filename)) {
693 1
                return PrefixSuffix::getSuffix($filename) . '/' . $relativePath;
694 1
            }
695
696
            return $relativePath;
697 1
        }
698
        // localized page (ie: if file is "page.fr.md", ID is "fr/page")
699
        if (PrefixSuffix::hasSuffix($filename)) {
700
            return trim(Util::joinPath(PrefixSuffix::getSuffix($filename), $relativePath, PrefixSuffix::sub($filename)), '/');
0 ignored issues
show
It seems like Cecil\Collection\Page\Pr...x::getSuffix($filename) can also be of type null; however, parameter $path of Cecil\Util::joinPath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

700
            return trim(Util::joinPath(/** @scrutinizer ignore-type */ PrefixSuffix::getSuffix($filename), $relativePath, PrefixSuffix::sub($filename)), '/');
Loading history...
701
        }
702
703
        return trim(Util::joinPath($relativePath, $filename), '/');
704
    }
705
706
    /**
707
     * Cast "boolean" string (or array of strings) to boolean.
708
     *
709 1
     * @param mixed $value Value to filter
710
     *
711 1
     * @return bool|mixed
712 1
     *
713 1
     * @see strToBool()
714
     */
715
    private function filterBool(&$value)
716
    {
717
        \Cecil\Util\Str::strToBool($value);
718
        if (\is_array($value)) {
719
            array_walk_recursive($value, '\Cecil\Util\Str::strToBool');
720
        }
721
    }
722
}
723