• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 21912628613

11 Feb 2026 04:04PM UTC coverage: 79.951% (+3.0%) from 76.947%
21912628613

push

github

ArnaudLigny
Require pages.parallel for pcntl-based conversion

Add a new config option pages.parallel (default false) to control whether page conversion uses parallel processing. Update Convert step to only use pcntl_fork when this flag is enabled and adjust debug logging; sequential processing remains the fallback.

1 of 2 new or added lines in 1 file covered. (50.0%)

67 existing lines in 1 file now uncovered.

3294 of 4120 relevant lines covered (79.95%)

0.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

24.68
/src/Step/Pages/Convert.php
1
<?php
2

3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <arnaud@ligny.fr>
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\Page;
18
use Cecil\Converter\Converter;
19
use Cecil\Converter\ConverterInterface;
20
use Cecil\Exception\RuntimeException;
21
use Cecil\Step\AbstractStep;
22
use Cecil\Util;
23
use Psr\Log\NullLogger;
24

25
/**
26
 * Convert step.
27
 *
28
 * This step is responsible for converting pages from their source format
29
 * (i.e. Markdown) to HTML, applying front matter processing,
30
 * and ensuring that the pages are ready for rendering. It handles both
31
 * published and draft pages, depending on the build options.
32
 *
33
 * When the `pcntl` extension is available, page conversions are parallelized
34
 * using pcntl_fork for better performance. Each child process inherits the
35
 * full Builder context (pages collection, config, etc.) via copy-on-write.
36
 */
37
class Convert extends AbstractStep
38
{
39
    /**
40
     * {@inheritdoc}
41
     */
42
    public function getName(): string
43
    {
44
        if ($this->builder->getBuildOptions()['drafts']) {
1✔
45
            return 'Converting pages (drafts included)';
1✔
46
        }
47

48
        return 'Converting pages';
×
49
    }
50

51
    /**
52
     * {@inheritdoc}
53
     */
54
    public function init(array $options): void
55
    {
56
        parent::init($options);
1✔
57

58
        if (\is_null($this->builder->getPages())) {
1✔
59
            $this->canProcess = false;
×
60
        }
61
    }
62

63
    /**
64
     * {@inheritdoc}
65
     */
66
    public function process(): void
67
    {
68
        if (!is_iterable($this->builder->getPages()) || \count($this->builder->getPages()) == 0) {
1✔
69
            return;
×
70
        }
71

72
        // Use parallel processing if pcntl is available, otherwise fall back to sequential
73
        if (\function_exists('pcntl_fork') && $this->config->get('pages.parallel')) {
1✔
NEW
74
            $this->builder->getLogger()->debug('Using parallel processing (pcntl extension available)');
×
UNCOV
75
            $this->processParallel();
×
76

UNCOV
77
            return;
×
78
        }
79

80
        $this->processSequential();
1✔
81
    }
82

83
    /**
84
     * Processes pages sequentially (original behavior).
85
     */
86
    protected function processSequential(): void
87
    {
88
        $total = \count($this->builder->getPages());
1✔
89
        $count = 0;
1✔
90
        /** @var Page $page */
91
        foreach ($this->builder->getPages() as $page) {
1✔
92
            if (!$page->isVirtual()) {
1✔
93
                $count++;
1✔
94

95
                try {
96
                    $convertedPage = $this->convertPage($this->builder, $page);
1✔
97
                    // set default language (ex: "en") if necessary
98
                    if ($convertedPage->getVariable('language') === null) {
1✔
99
                        $convertedPage->setVariable('language', $this->config->getLanguageDefault());
1✔
100
                    }
101
                } catch (RuntimeException $e) {
1✔
102
                    $this->builder->getLogger()->error(\sprintf('Unable to convert "%s:%s": %s', $e->getFile(), $e->getLine(), $e->getMessage()));
1✔
103
                    $this->builder->getPages()->remove($page->getId());
1✔
104
                    continue;
1✔
105
                } catch (\Exception $e) {
×
106
                    $this->builder->getLogger()->error(\sprintf('Unable to convert "%s": %s', Util::joinPath(Util\File::getFS()->makePathRelative($page->getFilePath(), $this->config->getPagesPath())), $e->getMessage()));
×
107
                    $this->builder->getPages()->remove($page->getId());
×
108
                    continue;
×
109
                }
110
                $message = \sprintf('Page "%s" converted', $page->getId());
1✔
111
                $statusMessage = ' (not published)';
1✔
112
                // forces drafts convert?
113
                if ($this->builder->getBuildOptions()['drafts']) {
1✔
114
                    $page->setVariable('published', true);
1✔
115
                }
116
                // replaces page in collection
117
                if ($page->getVariable('published')) {
1✔
118
                    $this->builder->getPages()->replace($page->getId(), $convertedPage);
1✔
119
                    $statusMessage = '';
1✔
120
                }
121
                $this->builder->getLogger()->info($message . $statusMessage, ['progress' => [$count, $total]]);
1✔
122
            }
123
        }
124
    }
125

126
    /**
127
     * Processes pages in parallel using pcntl_fork.
128
     *
129
     * Each child process inherits the parent's memory (copy-on-write), so the full Builder context (pages collection, config, converters, etc.) is available.
130
     * Only the conversion results (variables array + HTML string) are serialized back to the parent via temporary files.
131
     */
132
    protected function processParallel(): void
133
    {
134
        // Collect non-virtual pages
UNCOV
135
        $pages = [];
×
136
        /** @var Page $page */
UNCOV
137
        foreach ($this->builder->getPages() as $page) {
×
UNCOV
138
            if (!$page->isVirtual()) {
×
UNCOV
139
                $pages[] = $page;
×
140
            }
141
        }
142

UNCOV
143
        $total = \count($pages);
×
UNCOV
144
        if ($total === 0) {
×
145
            return;
×
146
        }
147

UNCOV
148
        $concurrency = $this->getConcurrency($total);
×
UNCOV
149
        $this->builder->getLogger()->debug(\sprintf('Using concurrency level: %d (total pages: %d)', $concurrency, $total));
×
UNCOV
150
        $chunks = array_chunk($pages, max(1, (int) ceil($total / $concurrency)));
×
UNCOV
151
        $format = (string) $this->builder->getConfig()->get('pages.frontmatter');
×
UNCOV
152
        $drafts = (bool) $this->options['drafts'];
×
153

UNCOV
154
        $children = [];
×
155

UNCOV
156
        foreach ($chunks as $chunk) {
×
UNCOV
157
            $tmpFile = tempnam(sys_get_temp_dir(), 'cecil_convert_');
×
UNCOV
158
            if ($tmpFile === false) {
×
159
                throw new RuntimeException('Unable to create temporary file for pages conversion.');
×
160
            }
161

UNCOV
162
            $pid = pcntl_fork();
×
163

UNCOV
164
            if ($pid === -1) {
×
165
                // Fork failed: convert this chunk sequentially in the parent
166
                $this->convertChunk($chunk, $format, $drafts, $tmpFile);
×
167
                $children[] = ['pid' => null, 'tmpFile' => $tmpFile, 'pages' => $chunk];
×
168
                continue;
×
169
            }
170

UNCOV
171
            if ($pid === 0) {
×
172
                // Child process
173
                // Silence logger to avoid output conflicts with parent
174
                $this->builder->setLogger(new NullLogger());
×
175

176
                $this->convertChunk($chunk, $format, $drafts, $tmpFile);
×
177

178
                // Exit child without running parent's shutdown handlers
179
                exit(0);
×
180
            }
181

182
            // Parent process
UNCOV
183
            $children[] = ['pid' => $pid, 'tmpFile' => $tmpFile, 'pages' => $chunk];
×
184
        }
185

186
        // Wait for all child processes and apply results
UNCOV
187
        $count = 0;
×
UNCOV
188
        foreach ($children as $child) {
×
UNCOV
189
            if ($child['pid'] !== null) {
×
UNCOV
190
                pcntl_waitpid($child['pid'], $status);
×
191
            }
UNCOV
192
            $this->builder->getLogger()->debug(\sprintf('Applying conversion results from child process (PID: %s, status: %s)', $child['pid'] ?? 'N/A', $status ?? 'N/A'));
×
UNCOV
193
            $count = $this->applyChunkResults($child['pages'], $child['tmpFile'], $total, $count);
×
194
        }
195
    }
196

197
    /**
198
     * Converts a chunk of pages using the existing Builder and writes
199
     * results to a temporary file.
200
     *
201
     * Each page's conversion output (front matter variables + HTML body) is
202
     * stored as serializable primitives (arrays, strings).
203
     *
204
     * @param Page[] $pages
205
     */
206
    protected function convertChunk(array $pages, string $format, bool $drafts, string $tmpFile): void
207
    {
208
        $converter = new Converter($this->builder);
×
209
        $results = [];
×
210

211
        foreach ($pages as $page) {
×
212
            $pageId = $page->getId();
×
213

214
            try {
215
                $variables = null;
×
216
                $html = null;
×
217

218
                // Convert front matter
219
                if ($page->getFrontmatter()) {
×
220
                    $variables = $converter->convertFrontmatter($page->getFrontmatter(), $format);
×
221
                }
222

223
                // Determine effective published status by applying variables to a cloned page
224
                // This ensures side effects (e.g., schedule logic) are applied, matching convertPage() behavior
225
                $published = (bool) $page->getVariable('published');
×
226
                if ($variables !== null) {
×
227
                    $tempPage = clone $page;
×
228
                    $tempPage->setVariables($variables);
×
229
                    $published = (bool) $tempPage->getVariable('published');
×
230
                }
231

232
                // Convert body (only if page is published or drafts option is enabled)
233
                if ($published || $drafts) {
×
234
                    $html = $converter->convertBody($page->getBody() ?? '');
×
235
                }
236

237
                $results[$pageId] = [
×
238
                    'success' => true,
×
239
                    'variables' => $variables,
×
240
                    'html' => $html,
×
241
                ];
×
242
            } catch (\Throwable $e) {
×
243
                $results[$pageId] = [
×
244
                    'success' => false,
×
245
                    'error' => $e->getMessage(),
×
246
                ];
×
247
            }
248
        }
249

250
        file_put_contents($tmpFile, serialize($results));
×
251
    }
252

253
    /**
254
     * Reads conversion results from a temporary file and applies them
255
     * to the original Page objects in the parent process.
256
     *
257
     * @param Page[] $pages
258
     *
259
     * @return int Updated progress count
260
     */
261
    protected function applyChunkResults(array $pages, string $tmpFile, int $total, int $count): int
262
    {
UNCOV
263
        $results = [];
×
264

UNCOV
265
        if (file_exists($tmpFile)) {
×
UNCOV
266
            $data = file_get_contents($tmpFile);
×
UNCOV
267
            if ($data !== false && $data !== '') {
×
UNCOV
268
                $unserialized = @unserialize($data, ['allowed_classes' => [\DateTimeImmutable::class]]);
×
UNCOV
269
                if (\is_array($unserialized)) {
×
UNCOV
270
                    $results = $unserialized;
×
271
                } else {
272
                    $this->builder->getLogger()->warning(\sprintf('Invalid conversion results in temporary file "%s". Ignoring.', $tmpFile));
×
273
                }
274
            }
UNCOV
275
            @unlink($tmpFile);
×
276
        }
277

UNCOV
278
        foreach ($pages as $page) {
×
UNCOV
279
            $pageId = $page->getId();
×
UNCOV
280
            $count++;
×
281

282
            // If results are missing or conversion failed, remove the page
UNCOV
283
            if (!isset($results[$pageId]) || !$results[$pageId]['success']) {
×
UNCOV
284
                $error = $results[$pageId]['error'] ?? 'Unknown error';
×
UNCOV
285
                $this->builder->getLogger()->error(\sprintf('Unable to convert "%s": %s', $pageId, $error));
×
UNCOV
286
                $this->builder->getPages()->remove($pageId);
×
UNCOV
287
                continue;
×
288
            }
289

UNCOV
290
            $result = $results[$pageId];
×
291

292
            // Apply front matter variables
UNCOV
293
            if ($result['variables'] !== null) {
×
UNCOV
294
                $page->setFmVariables($result['variables']);
×
UNCOV
295
                $page->setVariables($result['variables']);
×
296
            }
297

298
            // Apply converted HTML body
UNCOV
299
            if ($result['html'] !== null) {
×
UNCOV
300
                $page->setBodyHtml($result['html']);
×
301
            }
302

303
            // Set default language if necessary
UNCOV
304
            if ($page->getVariable('language') === null) {
×
UNCOV
305
                $page->setVariable('language', $this->config->getLanguageDefault());
×
306
            }
307

UNCOV
308
            $message = \sprintf('Page "%s" converted', $pageId);
×
UNCOV
309
            $statusMessage = ' (not published)';
×
310

311
            // Forces drafts convert?
UNCOV
312
            if ($this->builder->getBuildOptions()['drafts']) {
×
UNCOV
313
                $page->setVariable('published', true);
×
314
            }
315

316
            // Replaces page in collection
UNCOV
317
            if ($page->getVariable('published')) {
×
UNCOV
318
                $this->builder->getPages()->replace($pageId, $page);
×
UNCOV
319
                $statusMessage = '';
×
320
            }
321

UNCOV
322
            $this->builder->getLogger()->info($message . $statusMessage, ['progress' => [$count, $total]]);
×
323
        }
324

UNCOV
325
        return $count;
×
326
    }
327

328
    /**
329
     * Determines the optimal concurrency level based on CPU count.
330
     */
331
    protected function getConcurrency(int $totalPages): int
332
    {
UNCOV
333
        $cpuCount = 4; // sensible default
×
334

UNCOV
335
        if (PHP_OS_FAMILY === 'Darwin') {
×
336
            $result = @shell_exec('sysctl -n hw.ncpu 2>/dev/null');
×
337
            if (\is_string($result)) {
×
338
                $cpuCount = max(1, (int) trim($result));
×
339
            }
UNCOV
340
        } elseif (is_file('/proc/cpuinfo')) {
×
UNCOV
341
            $content = @file_get_contents('/proc/cpuinfo');
×
UNCOV
342
            if ($content !== false) {
×
UNCOV
343
                $cpuCount = max(1, substr_count($content, 'processor'));
×
344
            }
345
        } elseif (PHP_OS_FAMILY === 'Windows') {
×
346
            $result = @shell_exec('echo %NUMBER_OF_PROCESSORS% 2>NUL');
×
347
            if (\is_string($result)) {
×
348
                $cpuCount = max(1, (int) trim($result));
×
349
            }
350
        }
351

UNCOV
352
        return min($cpuCount, $totalPages);
×
353
    }
354

355
    /**
356
     * Converts page content:
357
     *  - front matter to PHP array
358
     *  - body to HTML.
359
     *
360
     * @throws RuntimeException
361
     */
362
    public function convertPage(Builder $builder, Page $page, ?string $format = null, ?ConverterInterface $converter = null): Page
363
    {
364
        $format = $format ?? (string) $builder->getConfig()->get('pages.frontmatter');
1✔
365
        $converter = $converter ?? new Converter($builder);
1✔
366

367
        // converts front matter
368
        if ($page->getFrontmatter()) {
1✔
369
            try {
370
                $variables = $converter->convertFrontmatter($page->getFrontmatter(), $format);
1✔
371
            } catch (RuntimeException $e) {
1✔
372
                throw new RuntimeException($e->getMessage(), file: $page->getFilePath(), line: $e->getLine());
1✔
373
            }
374
            $page->setFmVariables($variables);
1✔
375
            $page->setVariables($variables);
1✔
376
        }
377

378
        // converts body (only if page is published or drafts option is enabled)
379
        if ($page->getVariable('published') || $this->options['drafts']) {
1✔
380
            try {
381
                $html = $converter->convertBody($page->getBody());
1✔
382
            } catch (RuntimeException $e) {
×
383
                throw new RuntimeException($e->getMessage(), file: $page->getFilePath(), line: $e->getLine());
×
384
            }
385
            $page->setBodyHtml($html);
1✔
386
        }
387

388
        return $page;
1✔
389
    }
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc