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

Cecilapp / Cecil / 24008027632

05 Apr 2026 06:45PM UTC coverage: 80.071%. First build
24008027632

Pull #2321

github

web-flow
Merge 4f931bb7b into 939d731e9
Pull Request #2321: feat: add sub-section support for pages

62 of 196 new or added lines in 4 files covered. (31.63%)

3375 of 4215 relevant lines covered (80.07%)

0.8 hits per line

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

64.74
/src/Generator/Section.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\Generator;
15

16
use Cecil\Collection\Page\Collection as PagesCollection;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Collection\Page\PrefixSuffix;
19
use Cecil\Collection\Page\Type;
20
use Cecil\Exception\RuntimeException;
21

22
/**
23
 * Section generator class.
24
 *
25
 * This class is responsible for generating sections from the pages in the builder.
26
 * It identifies sections based on the 'section' variable in each page, and
27
 * creates a new page for each section. The generated pages are added to the
28
 * collection of generated pages. It also handles sorting of subpages and
29
 * adding navigation links (next and previous) to the section pages.
30
 *
31
 * Sub-sections support:
32
 * When a subfolder inside pages/ contains an index.md file, it is treated as a
33
 * sub-section. Pages within that subfolder are assigned to the sub-section rather
34
 * than the root section. Parent/child relationships are established between
35
 * sections to form a tree structure.
36
 */
37
class Section extends AbstractGenerator implements GeneratorInterface
38
{
39
    /**
40
     * {@inheritdoc}
41
     */
42
    public function generate(): void
43
    {
44
        // Step 1: Detect nested section paths (subfolders with index.md),
45
        // which are only enabled when `pages.sections.nested` is set to true
46
        // in the configuration.
47
        $nestedSectionPaths = [];
1✔
48
        if ((bool) $this->config->get('pages.sections.nested') === true) {
1✔
49
            // Returns a map of slugified-folder-path => page-id.
NEW
50
            $nestedSectionPaths = $this->detectNestedSectionPaths();
×
51
        }
52

53
        // Build a reverse map: page-id => folder-path (for looking up a page's original folder).
54
        $pageIdToFolderPath = [];
1✔
55
        foreach ($this->builder->getPages() ?? [] as $p) {
1✔
56
            $filepath = $p->getVariable('filepath');
1✔
57
            if ($filepath) {
1✔
58
                $dir = str_replace(DIRECTORY_SEPARATOR, '/', \dirname($filepath));
1✔
59
                $folderPath = ($dir === '.') ? '' : Page::slugify($dir);
1✔
60
                $pageIdToFolderPath[$p->getId()] = $folderPath;
1✔
61
            }
62
        }
63

64
        // Step 2: Group pages into sections (deepest matching section).
65
        $sections = [];
1✔
66

67
        /** @var Page $page */
68
        foreach ($this->builder->getPages() ?? [] as $page) {
1✔
69
            if (!$page->getSection()) {
1✔
70
                continue;
1✔
71
            }
72
            // do not add "not published" and "not excluded" pages to its section
73
            if (
74
                $page->getVariable('published') !== true
1✔
75
                || ($page->getVariable('excluded') || $page->getVariable('exclude'))
1✔
76
            ) {
77
                continue;
1✔
78
            }
79

80
            $language = $page->getVariable('language', $this->config->getLanguageDefault());
1✔
81
            $pageId = $page->getId();
1✔
82

83
            // Use the original file folder path to resolve the section.
84
            $originalFolder = $pageIdToFolderPath[$pageId] ?? null;
1✔
85
            $sectionPath = $this->resolveSection($originalFolder, $page->getSection(), $nestedSectionPaths);
1✔
86

87
            // Don't add a section's own index page to its pages list.
88
            // A page is a section index if its page ID matches a nested section path.
89
            if ($pageId === $sectionPath || isset($nestedSectionPaths[$pageId])) {
1✔
NEW
90
                continue;
×
91
            }
92

93
            // Root section index pages: their path equals their section.
94
            $pagePath = $page->getPath();
1✔
95
            if ($pagePath === $page->getSection()) {
1✔
NEW
96
                continue;
×
97
            }
98

99
            // Update the page's section to the resolved (possibly nested) section path.
100
            if ($sectionPath !== $page->getSection()) {
1✔
NEW
101
                $page->setSection($sectionPath);
×
102
            }
103

104
            $sections[$sectionPath][$language][] = $page;
1✔
105
        }
106

107
        // Ensure all nested section paths and their ancestors exist in the sections map (even if empty).
108
        $pathsToEnsure = [];
1✔
109
        foreach ($nestedSectionPaths as $nestedPath => $_) { // @SuppressWarnings(PHPMD.UnusedLocalVariable)
1✔
NEW
110
            $pathsToEnsure[$nestedPath] = true;
×
111
            // Collect ancestor paths.
NEW
112
            $parts = explode('/', $nestedPath);
×
NEW
113
            array_pop($parts);
×
NEW
114
            while (!empty($parts)) {
×
NEW
115
                $pathsToEnsure[implode('/', $parts)] = true;
×
NEW
116
                array_pop($parts);
×
117
            }
118
        }
119
        foreach ($pathsToEnsure as $sectionPath => $_) {
1✔
NEW
120
            if (!isset($sections[$sectionPath])) {
×
NEW
121
                $this->ensureSectionExists($sections, $sectionPath);
×
122
            }
123
        }
124

125
        // Step 3: Create section pages.
126
        if (\count($sections) > 0) {
1✔
127
            $menuWeight = 100;
1✔
128

129
            // Sort section keys so parents are processed before children.
130
            $sectionKeys = array_keys($sections);
1✔
131
            usort($sectionKeys, function (string $a, string $b): int {
1✔
132
                return substr_count($a, '/') <=> substr_count($b, '/');
1✔
133
            });
1✔
134

135
            $sectionPages = []; // maps sectionPath/language => Page
1✔
136

137
            foreach ($sectionKeys as $section) {
1✔
138
                $languages = $sections[$section];
1✔
139
                foreach ($languages as $language => $pagesAsArray) {
1✔
140
                    $pageId = $path = Page::slugify($section);
1✔
141
                    if ($language != $this->config->getLanguageDefault()) {
1✔
142
                        $pageId = "$language/$pageId";
1✔
143
                    }
144
                    $page = (new Page($pageId))->setVariable('title', ucfirst(basename($section)))
1✔
145
                        ->setPath($path);
1✔
146
                    if ($this->builder->getPages()->has($pageId)) {
1✔
147
                        $page = clone $this->builder->getPages()->get($pageId);
1✔
148
                    }
149
                    $pages = new PagesCollection("section-$pageId", $pagesAsArray);
1✔
150
                    // cascade variables
151
                    if ($page->hasVariable('cascade')) {
1✔
152
                        $cascade = $page->getVariable('cascade');
1✔
153
                        if (\is_array($cascade)) {
1✔
154
                            $pages->map(function (Page $page) use ($cascade) {
1✔
155
                                foreach ($cascade as $key => $value) {
1✔
156
                                    if (!$page->hasVariable($key)) {
1✔
157
                                        $page->setVariable($key, $value);
1✔
158
                                    }
159
                                }
160
                            });
1✔
161
                        }
162
                    }
163
                    // sorts pages
164
                    $sortBy = $page->getVariable('sortby') ?? $this->config->get('pages.sortby');
1✔
165
                    $pages = $pages->sortBy($sortBy);
1✔
166
                    // adds navigation links (excludes taxonomy pages)
167
                    $sortByVar = $page->getVariable('sortby')['variable'] ?? $page->getVariable('sortby') ?? $this->config->get('pages.sortby')['variable'] ?? $this->config->get('pages.sortby') ?? 'date';
1✔
168
                    if (!\in_array($page->getId(), array_keys((array) $this->config->get('taxonomies')))) {
1✔
169
                        $this->addNavigationLinks($pages, $sortByVar, $page->getVariable('circular') ?? false);
1✔
170
                    }
171
                    // creates page for each section
172
                    $page->setType(Type::SECTION->value)
1✔
173
                        ->setSection($path)
1✔
174
                        ->setPages($pages)
1✔
175
                        ->setVariable('language', $language)
1✔
176
                        ->setVariable('langref', $path);
1✔
177
                    $firstPage = $pages->first();
1✔
178
                    if ($firstPage instanceof Page && $firstPage->hasVariable('date')) {
1✔
179
                        $page->setVariable('date', $firstPage->getVariable('date'));
1✔
180
                    } else {
181
                        // Ensure the section always has a 'date' variable, even if it has no direct pages
NEW
182
                        $page->setVariable('date', null);
×
183
                    }
184
                    // human readable title
185
                    if ($page->getVariable('title') == 'index') {
1✔
186
                        $page->setVariable('title', basename($section));
1✔
187
                    }
188
                    // default menu: only root sections get a default menu entry
189
                    if (!str_contains($section, '/') && !$page->getVariable('menu')) {
1✔
190
                        $page->setVariable('menu', ['main' => ['weight' => $menuWeight]]);
1✔
191
                    }
192

193
                    try {
194
                        $this->generatedPages->add($page);
1✔
195
                    } catch (\DomainException) {
×
196
                        $this->generatedPages->replace($page->getId(), $page);
×
197
                    }
198

199
                    $sectionPages["$path|$language"] = $page;
1✔
200
                }
201

202
                if (!str_contains($section, '/')) {
1✔
203
                    $menuWeight += 10;
1✔
204
                }
205
            }
206

207
            // Step 4: Build parent/child relationships between sections.
208
            $this->buildSectionTree($sectionPages);
1✔
209
        }
210
    }
211

212
    /**
213
     * Detects nested section paths by finding pages created from index.md files
214
     * that are in subdirectories (nested deeper than the root section level).
215
     *
216
     * Uses original file paths (not transformed page paths) to correctly detect
217
     * hierarchy even when custom path patterns (e.g., date-based paths) are configured.
218
     *
219
     * @return array<string, true> Map of nested section paths (slugified folder paths)
220
     */
221
    protected function detectNestedSectionPaths(): array
222
    {
NEW
223
        $nestedPaths = [];
×
224

225
        /** @var Page $page */
NEW
226
        foreach ($this->builder->getPages() ?? [] as $page) {
×
NEW
227
            if ($page->isVirtual() || $page->getType() === Type::HOMEPAGE->value) {
×
NEW
228
                continue;
×
229
            }
230

NEW
231
            $filepath = $page->getVariable('filepath');
×
NEW
232
            if (!$filepath) {
×
NEW
233
                continue;
×
234
            }
235

236
            // Get the original directory from the filepath.
NEW
237
            $dir = str_replace(DIRECTORY_SEPARATOR, '/', \dirname($filepath));
×
NEW
238
            if ($dir === '.' || !str_contains($dir, '/')) {
×
NEW
239
                continue; // Root-level folders are not "nested"
×
240
            }
241

242
            // Check if this page was created from an index file.
NEW
243
            $extension = pathinfo($filepath, PATHINFO_EXTENSION);
×
NEW
244
            $filename = basename($filepath, '.' . $extension);
×
NEW
245
            $cleanName = strtolower(PrefixSuffix::sub($filename));
×
246

NEW
247
            if ($cleanName === 'index' || $cleanName === 'readme') {
×
248
                // Use the slugified directory as the nested section path.
NEW
249
                $folderPath = Page::slugify($dir);
×
NEW
250
                $nestedPaths[$folderPath] = true;
×
251
            }
252
        }
253

NEW
254
        return $nestedPaths;
×
255
    }
256

257
    /**
258
     * Resolves the deepest matching section for a page based on its original folder path.
259
     *
260
     * If the page's original folder matches a nested section path, it is assigned to
261
     * that sub-section. Otherwise, it stays in its root section.
262
     *
263
     * @param string|null          $originalFolder     The page's original file folder (slugified)
264
     * @param string               $rootSection        The page's current root section
265
     * @param array<string, true>  $nestedSectionPaths Map of nested section paths
266
     *
267
     * @return string The resolved section path
268
     */
269
    protected function resolveSection(?string $originalFolder, string $rootSection, array $nestedSectionPaths): string
270
    {
271
        if ($originalFolder === null || empty($nestedSectionPaths)) {
1✔
272
            return $rootSection;
1✔
273
        }
274

275
        // Try to find the deepest nested section matching this page's original folder.
276
        // Start from the full folder path and walk up.
NEW
277
        $parts = explode('/', $originalFolder);
×
278

NEW
279
        while (!empty($parts)) {
×
NEW
280
            $candidate = implode('/', $parts);
×
NEW
281
            if (isset($nestedSectionPaths[$candidate])) {
×
NEW
282
                return $candidate;
×
283
            }
NEW
284
            array_pop($parts);
×
285
        }
286

NEW
287
        return $rootSection;
×
288
    }
289

290
    /**
291
     * Builds parent/child relationships between section pages.
292
     *
293
     * @param array<string, Page>  $sectionPages Map of "path|language" => section Page
294
     */
295
    protected function buildSectionTree(array $sectionPages): void
296
    {
297
        foreach ($sectionPages as $key => $sectionPage) {
1✔
298
            [$path, $language] = explode('|', $key);
1✔
299

300
            if (!str_contains($path, '/')) {
1✔
301
                continue; // Root sections have no parent
1✔
302
            }
303

304
            // Find the closest parent section.
NEW
305
            $parts = explode('/', $path);
×
NEW
306
            array_pop($parts);
×
307

NEW
308
            while (!empty($parts)) {
×
NEW
309
                $parentPath = implode('/', $parts);
×
NEW
310
                $parentKey = "$parentPath|$language";
×
311

NEW
312
                if (isset($sectionPages[$parentKey])) {
×
NEW
313
                    $parentPage = $sectionPages[$parentKey];
×
314

315
                    // Set parent/child relationship
NEW
316
                    $sectionPage->setParentSection($parentPage);
×
NEW
317
                    $parentPage->addSubSection($sectionPage);
×
318

319
                    // Update generated pages collections
320
                    try {
NEW
321
                        $this->generatedPages->replace($sectionPage->getId(), $sectionPage);
×
NEW
322
                    } catch (\DomainException) {
×
323
                        // ignore
324
                    }
325
                    try {
NEW
326
                        $this->generatedPages->replace($parentPage->getId(), $parentPage);
×
NEW
327
                    } catch (\DomainException) {
×
328
                        // ignore
329
                    }
330

NEW
331
                    break;
×
332
                }
333

NEW
334
                array_pop($parts);
×
335
            }
336
        }
337
    }
338

339
    /**
340
     * Ensures that a section entry exists for the given path.
341
     *
342
     * Looks up the corresponding index page to determine the language,
343
     * then creates an empty section entry.
344
     *
345
     * @param array<string, array<string, list<Page>>>  &$sections    The sections map (modified in-place)
346
     * @param string                                     $sectionPath The section path (already slugified)
347
     */
348
    private function ensureSectionExists(array &$sections, string $sectionPath): void
349
    {
NEW
350
        $slug = Page::slugify($sectionPath);
×
351

352
        // Determine the language for this section. Prefer the language from an existing
353
        // index page when available; otherwise, fall back to the default language.
NEW
354
        if ($this->builder->getPages()->has($slug)) {
×
NEW
355
            $lang = $this->builder->getPages()->get($slug)
×
NEW
356
                ->getVariable('language', $this->config->getLanguageDefault());
×
357
        } else {
NEW
358
            $lang = $this->config->getLanguageDefault();
×
359
        }
360

361
        // Ensure the section entry exists for the resolved language.
NEW
362
        if (!isset($sections[$sectionPath][$lang])) {
×
NEW
363
            $sections[$sectionPath][$lang] = [];
×
364
        }
365
    }
366

367
    /**
368
     * Adds navigation (next and prev) to each pages of a section.
369
     */
370
    protected function addNavigationLinks(PagesCollection $pages, string|null $sortBy = null, bool $circular = false): void
371
    {
372
        $pagesAsArray = $pages->toArray();
1✔
373
        if ($sortBy === null || $sortBy == 'date' || $sortBy == 'updated') {
1✔
374
            $pagesAsArray = array_reverse($pagesAsArray);
1✔
375
        }
376
        $count = \count($pagesAsArray);
1✔
377
        if ($count > 1) {
1✔
378
            foreach ($pagesAsArray as $position => $page) {
1✔
379
                switch ($position) {
380
                    case 0: // first
1✔
381
                        if ($circular) {
1✔
382
                            $page->setVariables([
1✔
383
                                'prev' => $pagesAsArray[$count - 1],
1✔
384
                            ]);
1✔
385
                        }
386
                        $page->setVariables([
1✔
387
                            'next' => $pagesAsArray[$position + 1],
1✔
388
                        ]);
1✔
389
                        break;
1✔
390
                    case $count - 1: // last
1✔
391
                        $page->setVariables([
1✔
392
                            'prev' => $pagesAsArray[$position - 1],
1✔
393
                        ]);
1✔
394
                        if ($circular) {
1✔
395
                            $page->setVariables([
1✔
396
                                'next' => $pagesAsArray[0],
1✔
397
                            ]);
1✔
398
                        }
399
                        break;
1✔
400
                    default:
401
                        $page->setVariables([
1✔
402
                            'prev' => $pagesAsArray[$position - 1],
1✔
403
                            'next' => $pagesAsArray[$position + 1],
1✔
404
                        ]);
1✔
405
                        break;
1✔
406
                }
407
                $this->generatedPages->add($page);
1✔
408
            }
409
        }
410
    }
411
}
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