Render::addGlobals()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

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