Passed
Push — php-di ( 14dc40...53e65d )
by Arnaud
21:01 queued 17:21
created

Render::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 4
dl 0
loc 8
ccs 3
cts 3
cp 1
crap 1
rs 10
c 0
b 0
f 0
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\Step\Pages;
15
16
use Cecil\Builder;
17
use Cecil\Collection\Page\Collection;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\ConfigException;
21
use Cecil\Exception\RuntimeException;
22
use Cecil\Renderer\Config as RendererConfig;
23
use Cecil\Renderer\Layout;
24
use Cecil\Renderer\Page as PageRenderer;
25
use Cecil\Renderer\Site;
26
use Cecil\Renderer\Twig;
27
use Cecil\Renderer\Twig\TwigFactory;
28
use Cecil\Step\AbstractStep;
29
use Cecil\Util;
30
use Psr\Log\LoggerInterface;
31
32
/**
33
 * Render step.
34
 *
35
 * This step is responsible for rendering pages using Twig templates.
36
 * It processes each page, applies the appropriate templates, and generates
37
 * the final output formats. It also handles subsets of pages if specified,
38
 * and adds global variables to the renderer. The rendered pages are then
39
 * stored in the builder's pages collection for further processing or output.
40
 */
41
class Render extends AbstractStep
42
{
43
    public const TMP_DIR = '.cecil';
44
45
    protected $subset = [];
46
47
    private TwigFactory $twigFactory;
48
49 1
    public function __construct(
50
        Builder $builder,
51
        Config $config,
52
        LoggerInterface $logger,
53
        TwigFactory $twigFactory
54
    ) {
55 1
        parent::__construct($builder, $config, $logger);
56 1
        $this->twigFactory = $twigFactory;
57
    }
58
59
    /**
60
     * {@inheritdoc}
61
     */
62 1
    public function getName(): string
63
    {
64 1
        return 'Rendering pages';
65
    }
66
67
    /**
68
     * {@inheritdoc}
69
     */
70 1
    public function init(array $options): void
71
    {
72 1
        if (!is_dir($this->config->getLayoutsPath()) && !$this->config->hasTheme()) {
73
            $message = \sprintf('"%s" is not a valid layouts directory', $this->config->getLayoutsPath());
74
            $this->logger->debug($message);
75
        }
76
77
        // render a subset of pages?
78 1
        if (!empty($options['render-subset'])) {
79
            $subset = \sprintf('pages.subsets.%s', (string) $options['render-subset']);
80
            if (!$this->config->has($subset)) {
81
                throw new ConfigException(\sprintf('Subset "%s" not found.', $subset));
82
            }
83
            $this->subset = (array) $this->config->get($subset);
84
        }
85
86 1
        $this->canProcess = true;
87
    }
88
89
    /**
90
     * {@inheritdoc}
91
     *
92
     * @throws RuntimeException
93
     */
94 1
    public function process(): void
95
    {
96
        // prepares renderer
97 1
        $this->builder->setRenderer($this->twigFactory->create($this->getAllLayoutsPaths()));
98
99
        // adds global variables
100 1
        $this->addGlobals();
101
102 1
        $subset = $this->subset;
103
104
        /** @var Collection $pages */
105 1
        $pages = $this->builder->getPages()
106 1
            // published only
107 1
            ->filter(function (Page $page) {
108 1
                return (bool) $page->getVariable('published');
109 1
            })
110 1
            ->filter(function (Page $page) use ($subset) {
111 1
                if (empty($subset)) {
112 1
                    return true;
113
                }
114
                if (
115
                    !empty($subset['path'])
116
                    && !((bool) preg_match('/' . (string) $subset['path'] . '/i', $page->getPath()))
117
                ) {
118
                    return false;
119
                }
120
                if (!empty($subset['language'])) {
121
                    $language = $page->getVariable('language', $this->config->getLanguageDefault());
122
                    if ($language !== (string) $subset['language']) {
123
                        return false;
124
                    }
125
                }
126
                return true;
127 1
            })
128 1
            // enrichs some variables
129 1
            ->map(function (Page $page) {
130 1
                $formats = $this->getOutputFormats($page);
131
                // output formats
132 1
                $page->setVariable('output', $formats);
133
                // alternates formats
134 1
                $page->setVariable('alternates', $this->getAlternates($formats));
135
                // translations
136 1
                $page->setVariable('translations', $this->getTranslations($page));
137
138 1
                return $page;
139 1
            });
140 1
        $total = \count($pages);
141
142
        // renders each page
143 1
        $count = 0;
144 1
        $postprocessors = [];
145 1
        foreach ((array) $this->config->get('output.postprocessors') as $name => $postprocessor) {
146
            try {
147 1
                if (!class_exists($postprocessor)) {
148 1
                    throw new RuntimeException(\sprintf('Class "%s" not found', $postprocessor));
149
                }
150 1
                $postprocessors[] = new $postprocessor($this->builder);
151 1
                $this->logger->debug(\sprintf('Output post processor "%s" loaded', $name));
152 1
            } catch (\Exception $e) {
153 1
                $this->logger->error(\sprintf('Unable to load output post processor "%s": %s', $name, $e->getMessage()));
154
            }
155
        }
156
157
        // some cache to avoid multiple calls
158 1
        $cache = [];
159
160
        /** @var Page $page */
161 1
        foreach ($pages as $page) {
162 1
            $count++;
163 1
            $rendered = [];
164
165
            // l10n
166 1
            $language = $page->getVariable('language', $this->config->getLanguageDefault());
167 1
            if (!isset($cache['locale'][$language])) {
168 1
                $cache['locale'][$language] = $this->config->getLanguageProperty('locale', $language);
169
            }
170 1
            $this->builder->getRenderer()->setLocale($cache['locale'][$language]);
171
172
            // global site variables
173 1
            if (!isset($cache['site'][$language])) {
174 1
                $cache['site'][$language] = new Site($this->builder, $language);
175
            }
176 1
            $this->builder->getRenderer()->addGlobal('site', $cache['site'][$language]);
177
178
            // global config raw variables
179 1
            if (!isset($cache['config'][$language])) {
180 1
                $cache['config'][$language] = new RendererConfig($this->builder, $language);
181
            }
182 1
            $this->builder->getRenderer()->addGlobal('config', $cache['config'][$language]);
183
184
            // excluded format(s)?
185 1
            $formats = (array) $page->getVariable('output');
186 1
            foreach ($formats as $key => $format) {
187 1
                if ($exclude = $this->config->getOutputFormatProperty($format, 'exclude')) {
188
                    // ie:
189
                    //   formats:
190
                    //     atom:
191
                    //       [...]
192
                    //       exclude: [paginated]
193 1
                    if (!\is_array($exclude)) {
194
                        $exclude = [$exclude];
195
                    }
196 1
                    foreach ($exclude as $variable) {
197 1
                        if ($page->hasVariable($variable)) {
198 1
                            unset($formats[$key]);
199
                        }
200
                    }
201
                }
202
            }
203
204
            // specific output format from subset
205 1
            if (!empty($this->subset['output'])) {
206
                $currentFormats = $formats;
207
                $formats = [];
208
                if (\in_array((string) $this->subset['output'], $currentFormats)) {
209
                    $formats = [(string) $this->subset['output']];
210
                }
211
            }
212
213
            // renders each output format
214 1
            foreach ($formats as $format) {
215
                // search for the template
216 1
                $layout = Layout::finder($page, $format, $this->config);
217
                // renders with Twig
218
                try {
219 1
                    $deprecations = [];
220 1
                    set_error_handler(function ($type, $msg) use (&$deprecations) {
221 1
                        if (E_USER_DEPRECATED === $type) {
222 1
                            $deprecations[] = $msg;
223
                        }
224 1
                    });
225 1
                    $output = $this->builder->getRenderer()->render($layout['file'], ['page' => $page]);
226 1
                    foreach ($deprecations as $value) {
227 1
                        $this->builder->getLogger()->warning($value);
228
                    }
229 1
                    foreach ($postprocessors as $postprocessor) {
230 1
                        $output = $postprocessor->process($page, $output, $format);
231
                    }
232 1
                    $rendered[$format] = [
233 1
                        'output'   => $output,
234 1
                        'template' => [
235 1
                            'scope' => $layout['scope'],
236 1
                            'file'  => $layout['file'],
237 1
                        ],
238 1
                    ];
239 1
                    $page->addRendered($rendered);
240
                } catch (\Twig\Error\Error $e) {
241
                    throw new RuntimeException(
242
                        \sprintf(
243
                            'Unable to render template "%s" for page "%s".',
244
                            $e->getSourceContext()->getName(),
245
                            $page->getFileName() ?? $page->getId()
246
                        ),
247
                        previous: $e,
248
                        file: $e->getSourceContext()->getPath(),
249
                        line: $e->getTemplateLine(),
250
                    );
251
                } catch (\Exception $e) {
252
                    throw new RuntimeException($e->getMessage(), previous: $e);
253
                }
254
            }
255 1
            $this->builder->getPages()->replace($page->getId(), $page);
256
257 1
            $templates = array_column($rendered, 'template');
258 1
            $message = \sprintf(
259 1
                'Page "%s" rendered with [%s]',
260 1
                $page->getId() ?: 'index',
261 1
                Util\Str::combineArrayToString($templates, 'scope', 'file')
262 1
            );
263 1
            $this->builder->getLogger()->info($message, ['progress' => [$count, $total]]);
264
        }
265
        // profiler
266 1
        if ($this->builder->isDebug()) {
267
            try {
268
                // HTML
269 1
                $htmlDumper = new \Twig\Profiler\Dumper\HtmlDumper();
270 1
                $profileHtmlFile = Util::joinFile($this->config->getDestinationDir(), self::TMP_DIR, 'twig_profile.html');
271 1
                Util\File::getFS()->dumpFile($profileHtmlFile, $htmlDumper->dump($this->builder->getRenderer()->getDebugProfile()));
272
                // TXT
273 1
                $textDumper = new \Twig\Profiler\Dumper\TextDumper();
274 1
                $profileTextFile = Util::joinFile($this->config->getDestinationDir(), self::TMP_DIR, 'twig_profile.txt');
275 1
                Util\File::getFS()->dumpFile($profileTextFile, $textDumper->dump($this->builder->getRenderer()->getDebugProfile()));
276
                // log
277 1
                $this->builder->getLogger()->debug(\sprintf('Twig profile dumped in "%s"', Util::joinFile($this->config->getDestinationDir(), self::TMP_DIR)));
278
            } catch (\Symfony\Component\Filesystem\Exception\IOException $e) {
279
                throw new RuntimeException($e->getMessage());
280
            }
281
        }
282
    }
283
284
    /**
285
     * Returns an array of layouts directories.
286
     */
287 1
    protected function getAllLayoutsPaths(): array
288
    {
289 1
        $paths = [];
290
291
        // layouts/
292 1
        if (is_dir($this->config->getLayoutsPath())) {
293 1
            $paths[] = $this->config->getLayoutsPath();
294
        }
295
        // <theme>/layouts/
296 1
        if ($this->config->hasTheme()) {
297 1
            foreach ($this->config->getTheme() ?? [] as $theme) {
298 1
                $paths[] = $this->config->getThemeDirPath($theme);
299
            }
300
        }
301
        // resources/layouts/
302 1
        if (is_dir($this->config->getLayoutsInternalPath())) {
303 1
            $paths[] = $this->config->getLayoutsInternalPath();
304
        }
305
306 1
        return $paths;
307
    }
308
309
    /**
310
     * Adds global variables.
311
     */
312 1
    protected function addGlobals()
313
    {
314 1
        $this->builder->getRenderer()->addGlobal('cecil', [
315 1
            'url'       => \sprintf('https://cecil.app/#%s', Builder::getVersion()),
316 1
            'version'   => Builder::getVersion(),
317 1
            'poweredby' => \sprintf('Cecil v%s', Builder::getVersion()),
318 1
        ]);
319
    }
320
321
    /**
322
     * Get available output formats.
323
     *
324
     * @throws RuntimeException
325
     */
326 1
    protected function getOutputFormats(Page $page): array
327
    {
328
        // Get page output format(s) if defined.
329
        // ie:
330
        // ```yaml
331
        // output: txt
332
        // ```
333 1
        if ($page->getVariable('output')) {
334 1
            $formats = $page->getVariable('output');
335 1
            if (!\is_array($formats)) {
336 1
                $formats = [$formats];
337
            }
338
339 1
            return $formats;
340
        }
341
342
        // Get available output formats for the page type.
343
        // ie:
344
        // ```yaml
345
        // page: [html, json]
346
        // ```
347 1
        $formats = $this->config->get('output.pagetypeformats.' . $page->getType());
348 1
        if (empty($formats)) {
349
            throw new RuntimeException('Configuration key "pagetypeformats" can\'t be empty.');
350
        }
351 1
        if (!\is_array($formats)) {
352
            $formats = [$formats];
353
        }
354
355 1
        return array_unique($formats);
356
    }
357
358
    /**
359
     * Get alternates.
360
     */
361 1
    protected function getAlternates(array $formats): array
362
    {
363 1
        $alternates = [];
364
365 1
        if (\count($formats) > 1 || \in_array('html', $formats)) {
366 1
            foreach ($formats as $format) {
367 1
                $format == 'html' ? $rel = 'canonical' : $rel = 'alternate';
368 1
                $alternates[] = [
369 1
                    'rel'    => $rel,
370 1
                    'type'   => $this->config->getOutputFormatProperty($format, 'mediatype'),
371 1
                    'title'  => strtoupper($format),
372 1
                    'format' => $format,
373 1
                ];
374
            }
375
        }
376
377 1
        return $alternates;
378
    }
379
380
    /**
381
     * Returns the collection of translated pages for a given page.
382
     */
383 1
    protected function getTranslations(Page $refPage): Collection
384
    {
385 1
        $pages = $this->builder->getPages()->filter(function (Page $page) use ($refPage) {
386 1
            return $page->getVariable('langref') == $refPage->getVariable('langref')
387 1
                && $page->getType() == $refPage->getType()
388 1
                && $page->getId() !== $refPage->getId()
389 1
                && !empty($page->getVariable('published'))
390 1
                && !$page->getVariable('paginated')
391 1
            ;
392 1
        });
393
394 1
        return $pages;
395
    }
396
}
397