diff --git a/composer.lock b/composer.lock index f9d2d9bcd..bc06bf486 100644 --- a/composer.lock +++ b/composer.lock @@ -2614,7 +2614,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -2673,7 +2673,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" }, "funding": [ { @@ -2697,7 +2697,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -2755,7 +2755,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" }, "funding": [ { @@ -2867,7 +2867,7 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -2930,7 +2930,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0" }, "funding": [ { @@ -2954,7 +2954,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -3015,7 +3015,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" }, "funding": [ { @@ -3039,7 +3039,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -3100,7 +3100,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" }, "funding": [ { @@ -3124,7 +3124,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3180,7 +3180,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -3204,7 +3204,7 @@ }, { "name": "symfony/polyfill-php84", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", @@ -3260,7 +3260,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.36.0" }, "funding": [ { @@ -6731,11 +6731,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.1.51", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", "shasum": "" }, "require": { @@ -6780,7 +6780,7 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-04-21T18:22:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9250,7 +9250,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -9310,7 +9310,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.36.0" }, "funding": [ { @@ -9334,7 +9334,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -9390,7 +9390,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" }, "funding": [ { diff --git a/config/default.php b/config/default.php index bc6ee5d16..aa15cdca2 100644 --- a/config/default.php +++ b/config/default.php @@ -90,6 +90,9 @@ // ] //], 'frontmatter' => 'yaml', // front matter format: `yaml`, `ini`, `toml` or `json` + 'sections' => [ // sections options + //'nested' => true, // enable sub-sections (subfolders with index.md) + ], 'body' => [ 'toc' => ['h2', 'h3'], // headers used to build the table of contents 'highlight' => false, // enables code syntax highlighting diff --git a/docs/2-Content.md b/docs/2-Content.md index 548e84bb8..d996c3bc0 100644 --- a/docs/2-Content.md +++ b/docs/2-Content.md @@ -30,19 +30,26 @@ Project files organization. ```plaintext ├─ pages -| ├─ blog <- Section -| | ├─ post-1.md <- Page in Section -| | └─ post-2.md +| ├─ blog # Section +| | ├─ index.md # Section's index (optional) +| | ├─ post-1.md # Page in Section +| | ├─ post-2.md +| | └─ tutorials # Sub-section +| | ├─ index.md # Sub-section's index (required) +| | ├─ tuto-1.md # Page in Sub-section +| | └─ advanced # Nested Sub-section +| | ├─ index.md +| | └─ tuto-2.md | ├─ projects | | └─ project-a.md -| └─ about.md <- Root page +| └─ about.md # Root page ├─ assets -| ├─ styles.scss <- Asset file +| ├─ styles.scss # Asset file | └─ logo.png ├─ static -| └─ file.pdf <- Static file +| └─ file.pdf # Static file └─ data - └─ authors.yml <- Data collection + └─ authors.yml # Data collection ``` ### Built website tree @@ -52,11 +59,17 @@ Result of the build. ```plaintext └─ _site - ├─ index.html <- Generated home page + ├─ index.html # Generated home page ├─ blog/ - | ├─ index.html <- Generated list of posts - | ├─ post-1/index.html <- A blog post - | └─ post-2/index.html + | ├─ index.html # Generated list of posts + | ├─ post-1/index.html # A blog post + | ├─ post-2/index.html + | └─ tutorials/ + | ├─ index.html # Sub-section list + | ├─ tuto-1/index.html + | └─ advanced/ + | ├─ index.html # Nested sub-section list + | └─ tuto-2/index.html ├─ projects/ | ├─ index.html | └─ project-a/index.html @@ -571,7 +584,7 @@ It must be the first thing in the file and must be a valid [YAML](https://en.wik | `title` | Title | File name without extension. | `Post 1` | | `layout` | Template | See [_Lookup rules_](3-Templates.md#lookup-rules). | `404` | | `date` | Creation date | File creation date (PHP _DateTime_ object). | `2019/04/15` | -| `section` | Section | Page's _Section_. | `blog` | +| `section` | Section | Page's _Section_ (or _Sub-section_). | `blog` | | `path` | Path | Page's _path_. | `blog/post-1` | | `slug` | Slug | Page's _slug_. | `post-1` | | `published` | Published or not | `true`. | `false` | @@ -780,7 +793,7 @@ In « 1_The first project.md »: ### Section -Some dedicated variables can be used in a custom _Section_ (i.e.: `
/index.md`). +Some dedicated variables can be used in a custom _Section_ (i.e.: `
/index.md`) or _Sub-section_ (i.e.: `
//index.md`). #### sortby @@ -855,6 +868,41 @@ circular: true --- ``` +### Sub-sections + +A _Sub-section_ is created when a subfolder inside a _Section_ (or another _Sub-section_) contains an `index.md` file. This enables a hierarchical tree of sections. + +:::important +The `index.md` file is **required** to turn a subfolder into a _Sub-section_. Without it, the subfolder's pages belong to the nearest parent section. +::: + +#### Structure example + +```plaintext +pages/ +└─ blog/ + ├─ index.md <- "blog" Section + ├─ post-1.md + └─ tutorials/ + ├─ index.md <- "blog/tutorials" Sub-section + ├─ tutorial-1.md + └─ advanced/ + ├─ index.md <- "blog/tutorials/advanced" Sub-section + └─ tutorial-2.md +``` + +In this example: + +- `blog` is the root _Section_, containing `post-1`. +- `blog/tutorials` is a _Sub-section_ of `blog`, containing `tutorial-1`. +- `blog/tutorials/advanced` is a _Sub-section_ of `blog/tutorials`, containing `tutorial-2`. + +Each sub-section has its own pages collection and supports the same variables as a section (`sortby`, `pagination`, `cascade`, `circular`). + +#### Accessing sub-sections in templates + +See the [Sub-sections template variables](3-Templates.md#sub-sections) for details on how to use sub-sections in Twig templates. + ### Home page Like another section, _Home page_ support `sortby` and `pagination` configuration. diff --git a/docs/3-Templates.md b/docs/3-Templates.md index 4b0bc1994..198436b2d 100644 --- a/docs/3-Templates.md +++ b/docs/3-Templates.md @@ -196,6 +196,19 @@ All rules are detailed below, for each page type, in the priority order. 6. `list..twig` 7. `_default/list..twig` +#### Sub-section lookup + +For a sub-section (e.g. `blog/tutorials`), the layout lookup also includes the parent section layouts: + +1. `
//index..twig` +2. `
//list..twig` +3. `section/
/..twig` +4. `
/list..twig` _(parent section)_ +5. `section/
..twig` _(parent section)_ +6. `_default/section..twig` +7. `list..twig` +8. `_default/list..twig` + ### Type _vocabulary_ 1. `taxonomy/..twig` @@ -355,6 +368,8 @@ The `page` variable contains built-in variables of a page **and** those set in t | `page.filepath` | File system path. | `Blog/Post 1.md` | | `page.type` | `homepage`, `page`, `section`, `vocabulary` or `term`. | `page` | | `page.pages` | Collection of all sub pages. | _Collection_ | +| `page.subsections` | Collection of child sub-sections (sections only). | _Collection_ | +| `page.parent` | Parent section (sub-sections only). | _Page_ | | `page.translations` | Collection of translated pages. | _Collection_ | :::important @@ -822,6 +837,104 @@ The `dump` function dumps information about a template variable. This is mostly The [_debug mode_](4-Configuration.md#debug) must be enabled. ::: +### Sub-sections + +The following functions and [tests](#tests) help work with [sub-sections](2-Content.md#sub-sections). Sub-sections must be [enabled in the configuration](4-Configuration.md#pages-sections). + +#### subsections + +Returns the collection of child sub-sections of a _section_ page. + +```twig +{{ subsections(page) }} +``` + +_Example:_ + +```twig +{% if page is has_subsections %} + {% for sub in subsections(page) %} + {{ sub.title }} + {% endfor %} +{% endif %} +``` + +#### parent_section + +Returns the parent section of a sub-section page (or `null` if the page is a root section). + +```twig +{{ parent_section(page) }} +``` + +_Example:_ + +```twig +{% if page is subsection %} + Back to {{ parent_section(page).title }} +{% endif %} +``` + +#### section_breadcrumb + +Returns an array of pages from the root section down to the given section, useful for building breadcrumb navigation. + +```twig +{{ section_breadcrumb(page) }} +``` + +_Example:_ + +```twig +{% for crumb in section_breadcrumb(page) %} + {{ crumb.title }} + {% if not loop.last %} / {% endif %} +{% endfor %} +``` + +#### all_pages_recursive + +Returns all pages of a section including pages from its sub-sections, recursively. + +```twig +{{ all_pages_recursive(page) }} +``` + +_Example:_ + +```twig +{% for p in all_pages_recursive(page) %} + {{ p.title }} +{% endfor %} +``` + +#### section_tree + +Returns the full hierarchical tree of all sections as an array of nodes. Each node contains a `page` entry (the section page) and a `children` array of sub-nodes. + +```twig +{{ section_tree() }} +``` + +_Example:_ + +```twig +{% macro render_tree(tree) %} +
    + {% for node in tree %} +
  • + {{ node.page.title }} + {% if node.children is not empty %} + {{ _self.render_tree(node.children) }} + {% endif %} +
  • + {% endfor %} +
+{% endmacro %} + +{{ _self.render_tree(section_tree()) }} +``` + ### d The `d()` function is the HTML version of [`dump()`](#dump) and use the [Symfony VarDumper Component](https://symfony.com/doc/5.4/components/var_dumper.html) behind the scenes. @@ -837,6 +950,30 @@ The `d()` function is the HTML version of [`dump()`](#dump) and use the [Symfony The [_debug mode_](4-Configuration.md#debug) must be enabled. ::: +## Tests + +> [Tests](https://twig.symfony.com/doc/tests/index.html) can be used with the `is` operator to check if a variable meets certain criteria. + +### subsection + +Tests if a _section_ page is a sub-section (i.e. has a parent section). + +```twig +{% if page is subsection %} + This section is a sub-section. +{% endif %} +``` + +### has_subsections + +Tests if a _section_ page has child sub-sections. + +```twig +{% if page is has_subsections %} + This section contains sub-sections. +{% endif %} +``` + ## Sorts Sorting collections (of pages, menus or taxonomies). diff --git a/docs/4-Configuration.md b/docs/4-Configuration.md index b20491cbd..1f5005ed4 100644 --- a/docs/4-Configuration.md +++ b/docs/4-Configuration.md @@ -489,6 +489,22 @@ pages: pagination: false ``` +### pages.sections + +Options for [_Sections_](2-Content.md#section). + +#### Enable sub-sections + +Sub-sections (nested sections created by subfolders containing an `index.md` file) are disabled by default. Enable them with the `nested` option: + +```yaml +pages: + sections: + nested: true +``` + +See the [Content documentation](2-Content.md#sub-sections) for details on how to structure sub-sections and the [Templates documentation](3-Templates.md#sub-sections) for available functions and tests. + ### pages.paths Apply a custom [`path`](2-Content.md#predefined-variables) for all pages of a **_Section_**. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 05652e425..e616f3a48 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -27,6 +27,9 @@ ./tests/IntegrationCliTests.php + + ./tests/SubSectionTests.php + diff --git a/src/Collection/Page/Page.php b/src/Collection/Page/Page.php index 189926b18..afc302194 100644 --- a/src/Collection/Page/Page.php +++ b/src/Collection/Page/Page.php @@ -71,6 +71,12 @@ class Page extends Item /** @var array */ protected $paginator = []; + /** @var Page|null Parent section (for sub-sections). */ + protected $parentSection; + + /** @var Collection|null Child sub-sections collection. */ + protected $subSections; + /** @var \Cecil\Collection\Taxonomy\Vocabulary Terms of a vocabulary. */ protected $terms; @@ -554,6 +560,154 @@ public function getPagination(): array return $this->getPaginator(); } + /** + * Set the parent section (for sub-sections). + */ + public function setParentSection(?Page $parent): self + { + $this->parentSection = $parent; + + return $this; + } + + /** + * Get the parent section (for sub-sections). + */ + public function getParentSection(): ?Page + { + return $this->parentSection; + } + + /** + * Does this section have a parent section? + */ + public function hasParentSection(): bool + { + return $this->parentSection !== null; + } + + /** + * Get child sub-sections collection. + */ + public function getSubSections(): ?Collection + { + return $this->subSections; + } + + /** + * Set child sub-sections collection. + */ + public function setSubSections(Collection $subSections): self + { + $this->subSections = $subSections; + + return $this; + } + + /** + * Add a child sub-section. + */ + public function addSubSection(Page $child): self + { + if ($this->subSections === null) { + $this->subSections = new Collection(\sprintf('%s-subsections', $this->getId())); + } + + try { + $this->subSections->add($child); + } catch (\DomainException) { + $this->subSections->replace($child->getId(), $child); + } + + return $this; + } + + /** + * Does this section have child sub-sections? + */ + public function hasSubSections(): bool + { + return $this->subSections !== null && \count($this->subSections) > 0; + } + + /** + * Is this a sub-section (has a parent section)? + */ + public function isSubSection(): bool + { + return $this->type === Type::SECTION && $this->parentSection !== null; + } + + /** + * Get depth level in the section tree (0 = root section). + * Uses the parent section chain rather than path slashes for robustness + * when custom path patterns are configured. + */ + public function getSectionDepth(): int + { + $depth = 0; + $current = $this; + + while ($current->hasParentSection()) { + $depth++; + $current = $current->getParentSection(); + } + + return $depth; + } + + /** + * Returns the breadcrumb from root section to this section. + * + * @return Page[] + */ + public function getSectionBreadcrumb(): array + { + $breadcrumb = [$this]; + $current = $this; + + while ($current->hasParentSection()) { + $current = $current->getParentSection(); + array_unshift($breadcrumb, $current); + } + + return $breadcrumb; + } + + /** + * Get all pages recursively, including pages from sub-sections. + */ + public function getAllPagesRecursive(): Collection + { + $allPages = new Collection(\sprintf('%s-all-pages', $this->getId())); + + // Add direct pages + if ($this->getPages() !== null) { + foreach ($this->getPages() as $page) { + try { + $allPages->add($page); + } catch (\DomainException) { + // skip duplicates + } + } + } + + // Add pages from sub-sections recursively + if ($this->hasSubSections()) { + foreach ($this->getSubSections() as $subSection) { + foreach ($subSection->getAllPagesRecursive() as $page) { + try { + $allPages->add($page); + } catch (\DomainException) { + // skip duplicates + } + } + } + } + + return $allPages; + } + /** * Set vocabulary terms. */ diff --git a/src/Generator/Section.php b/src/Generator/Section.php index c3ec3b2e2..d125e4cf9 100644 --- a/src/Generator/Section.php +++ b/src/Generator/Section.php @@ -15,6 +15,7 @@ use Cecil\Collection\Page\Collection as PagesCollection; use Cecil\Collection\Page\Page; +use Cecil\Collection\Page\PrefixSuffix; use Cecil\Collection\Page\Type; use Cecil\Exception\RuntimeException; @@ -26,6 +27,12 @@ * creates a new page for each section. The generated pages are added to the * collection of generated pages. It also handles sorting of subpages and * adding navigation links (next and previous) to the section pages. + * + * Sub-sections support: + * When a subfolder inside pages/ contains an index.md file, it is treated as a + * sub-section. Pages within that subfolder are assigned to the sub-section rather + * than the root section. Parent/child relationships are established between + * sections to form a tree structure. */ class Section extends AbstractGenerator implements GeneratorInterface { @@ -34,35 +41,107 @@ class Section extends AbstractGenerator implements GeneratorInterface */ public function generate(): void { + // Step 1: Detect nested section paths (subfolders with index.md), + // which are only enabled when `pages.sections.nested` is set to true + // in the configuration. + $nestedSectionPaths = []; + if ((bool) $this->config->get('pages.sections.nested') === true) { + // Returns a map of slugified-folder-path => page-id. + $nestedSectionPaths = $this->detectNestedSectionPaths(); + } + + // Build a reverse map: page-id => folder-path (for looking up a page's original folder). + $pageIdToFolderPath = []; + foreach ($this->builder->getPages() ?? [] as $p) { + $filepath = $p->getVariable('filepath'); + if ($filepath) { + $dir = str_replace(DIRECTORY_SEPARATOR, '/', \dirname($filepath)); + $folderPath = ($dir === '.') ? '' : Page::slugify($dir); + $pageIdToFolderPath[$p->getId()] = $folderPath; + } + } + + // Step 2: Group pages into sections (deepest matching section). $sections = []; - // identifying sections from all pages /** @var Page $page */ foreach ($this->builder->getPages() ?? [] as $page) { - // top level (root) sections - if ($page->getSection()) { - // do not add "not published" and "not excluded" pages to its section - if ( - $page->getVariable('published') !== true - || ($page->getVariable('excluded') || $page->getVariable('exclude')) - ) { - continue; - } - $sections[$page->getSection()][$page->getVariable('language', $this->config->getLanguageDefault())][] = $page; + if (!$page->getSection()) { + continue; + } + // do not add "not published" and "not excluded" pages to its section + if ( + $page->getVariable('published') !== true + || ($page->getVariable('excluded') || $page->getVariable('exclude')) + ) { + continue; + } + + $language = $page->getVariable('language', $this->config->getLanguageDefault()); + $pageId = $page->getId(); + + // Use the original file folder path to resolve the section. + $originalFolder = $pageIdToFolderPath[$pageId] ?? null; + $sectionPath = $this->resolveSection($originalFolder, $page->getSection(), $nestedSectionPaths); + + // Don't add a section's own index page to its pages list. + // A page is a section index if its page ID matches a nested section path. + if ($pageId === $sectionPath || isset($nestedSectionPaths[$pageId])) { + continue; } + + // Root section index pages: their path equals their section. + $pagePath = $page->getPath(); + if ($pagePath === $page->getSection()) { + continue; + } + + // Update the page's section to the resolved (possibly nested) section path. + if ($sectionPath !== $page->getSection()) { + $page->setSection($sectionPath); + } + + $sections[$sectionPath][$language][] = $page; } - // adds each section to pages collection + // Ensure all nested section paths and their ancestors exist in the sections map (even if empty). + $pathsToEnsure = []; + foreach ($nestedSectionPaths as $nestedPath => $_) { // @SuppressWarnings(PHPMD.UnusedLocalVariable) + $pathsToEnsure[$nestedPath] = true; + // Collect ancestor paths. + $parts = explode('/', $nestedPath); + array_pop($parts); + while (!empty($parts)) { + $pathsToEnsure[implode('/', $parts)] = true; + array_pop($parts); + } + } + foreach ($pathsToEnsure as $sectionPath => $_) { + if (!isset($sections[$sectionPath])) { + $this->ensureSectionExists($sections, $sectionPath); + } + } + + // Step 3: Create section pages. if (\count($sections) > 0) { $menuWeight = 100; - foreach ($sections as $section => $languages) { + // Sort section keys so parents are processed before children. + $sectionKeys = array_keys($sections); + usort($sectionKeys, function (string $a, string $b): int { + return substr_count($a, '/') <=> substr_count($b, '/'); + }); + + $sectionPages = []; // maps sectionPath/language => Page + + foreach ($sectionKeys as $section) { + $languages = $sections[$section]; foreach ($languages as $language => $pagesAsArray) { $pageId = $path = Page::slugify($section); if ($language != $this->config->getLanguageDefault()) { $pageId = "$language/$pageId"; } - $page = (new Page($pageId))->setVariable('title', ucfirst($section)) + $page = (new Page($pageId))->setVariable('title', ucfirst(basename($section))) ->setPath($path); if ($this->builder->getPages()->has($pageId)) { $page = clone $this->builder->getPages()->get($pageId); @@ -85,23 +164,29 @@ public function generate(): void $sortBy = $page->getVariable('sortby') ?? $this->config->get('pages.sortby'); $pages = $pages->sortBy($sortBy); // adds navigation links (excludes taxonomy pages) - $sortBy = $page->getVariable('sortby')['variable'] ?? $page->getVariable('sortby') ?? $this->config->get('pages.sortby')['variable'] ?? $this->config->get('pages.sortby') ?? 'date'; + $sortByVar = $page->getVariable('sortby')['variable'] ?? $page->getVariable('sortby') ?? $this->config->get('pages.sortby')['variable'] ?? $this->config->get('pages.sortby') ?? 'date'; if (!\in_array($page->getId(), array_keys((array) $this->config->get('taxonomies')))) { - $this->addNavigationLinks($pages, $sortBy, $page->getVariable('circular') ?? false); + $this->addNavigationLinks($pages, $sortByVar, $page->getVariable('circular') ?? false); } // creates page for each section $page->setType(Type::SECTION->value) ->setSection($path) ->setPages($pages) ->setVariable('language', $language) - ->setVariable('date', $pages->first()->getVariable('date')) ->setVariable('langref', $path); + $firstPage = $pages->first(); + if ($firstPage instanceof Page && $firstPage->hasVariable('date')) { + $page->setVariable('date', $firstPage->getVariable('date')); + } else { + // Ensure the section always has a 'date' variable, even if it has no direct pages + $page->setVariable('date', null); + } // human readable title if ($page->getVariable('title') == 'index') { - $page->setVariable('title', $section); + $page->setVariable('title', basename($section)); } - // default menu - if (!$page->getVariable('menu')) { + // default menu: only root sections get a default menu entry + if (!str_contains($section, '/') && !$page->getVariable('menu')) { $page->setVariable('menu', ['main' => ['weight' => $menuWeight]]); } @@ -110,12 +195,175 @@ public function generate(): void } catch (\DomainException) { $this->generatedPages->replace($page->getId(), $page); } + + $sectionPages["$path|$language"] = $page; + } + + if (!str_contains($section, '/')) { + $menuWeight += 10; + } + } + + // Step 4: Build parent/child relationships between sections. + $this->buildSectionTree($sectionPages); + } + } + + /** + * Detects nested section paths by finding pages created from index.md files + * that are in subdirectories (nested deeper than the root section level). + * + * Uses original file paths (not transformed page paths) to correctly detect + * hierarchy even when custom path patterns (e.g., date-based paths) are configured. + * + * @return array Map of nested section paths (slugified folder paths) + */ + protected function detectNestedSectionPaths(): array + { + $nestedPaths = []; + + /** @var Page $page */ + foreach ($this->builder->getPages() ?? [] as $page) { + if ($page->isVirtual() || $page->getType() === Type::HOMEPAGE->value) { + continue; + } + + $filepath = $page->getVariable('filepath'); + if (!$filepath) { + continue; + } + + // Get the original directory from the filepath. + $dir = str_replace(DIRECTORY_SEPARATOR, '/', \dirname($filepath)); + if ($dir === '.' || !str_contains($dir, '/')) { + continue; // Root-level folders are not "nested" + } + + // Check if this page was created from an index file. + $extension = pathinfo($filepath, PATHINFO_EXTENSION); + $filename = basename($filepath, '.' . $extension); + $cleanName = strtolower(PrefixSuffix::sub($filename)); + + if ($cleanName === 'index' || $cleanName === 'readme') { + // Use the slugified directory as the nested section path. + $folderPath = Page::slugify($dir); + $nestedPaths[$folderPath] = true; + } + } + + return $nestedPaths; + } + + /** + * Resolves the deepest matching section for a page based on its original folder path. + * + * If the page's original folder matches a nested section path, it is assigned to + * that sub-section. Otherwise, it stays in its root section. + * + * @param string|null $originalFolder The page's original file folder (slugified) + * @param string $rootSection The page's current root section + * @param array $nestedSectionPaths Map of nested section paths + * + * @return string The resolved section path + */ + protected function resolveSection(?string $originalFolder, string $rootSection, array $nestedSectionPaths): string + { + if ($originalFolder === null || empty($nestedSectionPaths)) { + return $rootSection; + } + + // Try to find the deepest nested section matching this page's original folder. + // Start from the full folder path and walk up. + $parts = explode('/', $originalFolder); + + while (!empty($parts)) { + $candidate = implode('/', $parts); + if (isset($nestedSectionPaths[$candidate])) { + return $candidate; + } + array_pop($parts); + } + + return $rootSection; + } + + /** + * Builds parent/child relationships between section pages. + * + * @param array $sectionPages Map of "path|language" => section Page + */ + protected function buildSectionTree(array $sectionPages): void + { + foreach ($sectionPages as $key => $sectionPage) { + [$path, $language] = explode('|', $key); + + if (!str_contains($path, '/')) { + continue; // Root sections have no parent + } + + // Find the closest parent section. + $parts = explode('/', $path); + array_pop($parts); + + while (!empty($parts)) { + $parentPath = implode('/', $parts); + $parentKey = "$parentPath|$language"; + + if (isset($sectionPages[$parentKey])) { + $parentPage = $sectionPages[$parentKey]; + + // Set parent/child relationship + $sectionPage->setParentSection($parentPage); + $parentPage->addSubSection($sectionPage); + + // Update generated pages collections + try { + $this->generatedPages->replace($sectionPage->getId(), $sectionPage); + } catch (\DomainException) { + // ignore + } + try { + $this->generatedPages->replace($parentPage->getId(), $parentPage); + } catch (\DomainException) { + // ignore + } + + break; } - $menuWeight += 10; + + array_pop($parts); } } } + /** + * Ensures that a section entry exists for the given path. + * + * Looks up the corresponding index page to determine the language, + * then creates an empty section entry. + * + * @param array>> &$sections The sections map (modified in-place) + * @param string $sectionPath The section path (already slugified) + */ + private function ensureSectionExists(array &$sections, string $sectionPath): void + { + $slug = Page::slugify($sectionPath); + + // Determine the language for this section. Prefer the language from an existing + // index page when available; otherwise, fall back to the default language. + if ($this->builder->getPages()->has($slug)) { + $lang = $this->builder->getPages()->get($slug) + ->getVariable('language', $this->config->getLanguageDefault()); + } else { + $lang = $this->config->getLanguageDefault(); + } + + // Ensure the section entry exists for the resolved language. + if (!isset($sections[$sectionPath][$lang])) { + $sections[$sectionPath][$lang] = []; + } + } + /** * Adds navigation (next and prev) to each pages of a section. */ diff --git a/src/Renderer/Extension/Core.php b/src/Renderer/Extension/Core.php index 6a086b78d..f5e0c23f0 100644 --- a/src/Renderer/Extension/Core.php +++ b/src/Renderer/Extension/Core.php @@ -78,6 +78,12 @@ public function getFunctions() new \Twig\TwigFunction('readtime', [$this, 'readtime']), new \Twig\TwigFunction('hash', [$this, 'hash']), new \Twig\TwigFunction('cache_key', [$this, 'cacheKey'], ['needs_context' => true]), + // sub-sections + new \Twig\TwigFunction('subsections', [$this, 'getSubSections']), + new \Twig\TwigFunction('parent_section', [$this, 'getParentSectionFunc']), + new \Twig\TwigFunction('section_breadcrumb', [$this, 'getSectionBreadcrumb']), + new \Twig\TwigFunction('all_pages_recursive', [$this, 'getAllPagesRecursive']), + new \Twig\TwigFunction('section_tree', [$this, 'getSectionTree']), // others new \Twig\TwigFunction('getenv', [$this, 'getEnv']), new \Twig\TwigFunction('d', [$this, 'varDump'], ['needs_context' => true, 'needs_environment' => true]), @@ -168,6 +174,13 @@ public function getTests() new \Twig\TwigTest('asset', [$this, 'isAsset']), new \Twig\TwigTest('image_large', [$this, 'isImageLarge']), new \Twig\TwigTest('image_square', [$this, 'isImageSquare']), + // sub-sections + new \Twig\TwigTest('subsection', function (Page $page): bool { + return $page->isSubSection(); + }), + new \Twig\TwigTest('has_subsections', function (Page $page): bool { + return $page->hasSubSections(); + }), ]; } @@ -184,6 +197,92 @@ public function filterBySection(PagesCollection $pages, string $section): Collec return $this->filterBy($pages, 'section', $section); } + /** + * Returns child sub-sections of a section page. + * + * @return Page[]|PagesCollection|null + */ + public function getSubSections(Page $page): ?\Cecil\Collection\Page\Collection + { + if ($page->getType() !== Type::SECTION->value) { + return null; + } + + return $page->getSubSections(); + } + + /** + * Returns parent section of a sub-section. + */ + public function getParentSectionFunc(Page $page): ?Page + { + return $page->getParentSection(); + } + + /** + * Returns breadcrumb from root section to the given section. + * + * @return Page[] + */ + public function getSectionBreadcrumb(Page $page): array + { + return $page->getSectionBreadcrumb(); + } + + /** + * Returns all pages recursively, including pages from sub-sections. + */ + public function getAllPagesRecursive(Page $page): ?\Cecil\Collection\Page\Collection + { + if ($page->getType() !== Type::SECTION->value) { + return null; + } + + return $page->getAllPagesRecursive(); + } + + /** + * Returns the full section tree (root sections with nested children). + * + * @return array + */ + public function getSectionTree(): array + { + $tree = []; + $pages = $this->builder->getPages(); + if ($pages === null) { + return $tree; + } + + /** @var Page $page */ + foreach ($pages as $page) { + if ($page->getType() === Type::SECTION->value && !$page->hasParentSection()) { + $tree[$page->getId()] = $this->buildTreeNode($page); + } + } + + return $tree; + } + + /** + * Recursively builds a tree node for section_tree(). + */ + private function buildTreeNode(Page $page): array + { + $node = [ + 'page' => $page, + 'children' => [], + ]; + + if ($page->hasSubSections()) { + foreach ($page->getSubSections() as $child) { + $node['children'][$child->getId()] = $this->buildTreeNode($child); + } + } + + return $node; + } + /** * Filters a pages collection by variable's name/value. */ diff --git a/src/Renderer/Layout.php b/src/Renderer/Layout.php index c4100caa0..fb5e639c2 100644 --- a/src/Renderer/Layout.php +++ b/src/Renderer/Layout.php @@ -123,10 +123,25 @@ protected static function lookup(CollectionPage $page, string $format, \Cecil\Co "list.$format.$ext", "_default/list.$format.$ext", ]; - if ($page->getPath()) { + if ($section && $page->getPath()) { $layouts = array_merge(["section/{$section}.$format.$ext"], $layouts); $layouts = array_merge(["{$section}/list.$format.$ext"], $layouts); $layouts = array_merge(["{$section}/index.$format.$ext"], $layouts); + // Sub-section support: also try parent section layouts. + // For "blog/tutorials", also try "blog/list", "blog/index", "section/blog". + if (str_contains($section, '/')) { + $parentSection = substr($section, 0, strrpos($section, '/')); + $layouts = array_merge( + [ + "{$section}/index.$format.$ext", + "{$section}/list.$format.$ext", + "section/{$section}.$format.$ext", + "{$parentSection}/list.$format.$ext", + "section/{$parentSection}.$format.$ext", + ], + $layouts + ); + } } if ($page->hasVariable('layout')) { $layouts = array_merge(["$layout.$format.$ext"], $layouts); diff --git a/tests/SubSectionTests.php b/tests/SubSectionTests.php new file mode 100644 index 000000000..d64a26268 --- /dev/null +++ b/tests/SubSectionTests.php @@ -0,0 +1,213 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Cecil\Test; + +use Cecil\Builder; +use Cecil\Collection\Page\Page; +use Cecil\Collection\Page\Type; +use Cecil\Config; +use Cecil\Logger\PrintLogger; +use Cecil\Util; +use Symfony\Component\Filesystem\Filesystem; + +class SubSectionTests extends \PHPUnit\Framework\TestCase +{ + protected static $source; + protected static $config; + protected static $destination; + protected static $builder; + + public static function setUpBeforeClass(): void + { + self::$source = Util::joinFile(__DIR__, 'fixtures/website'); + self::$config = Util::joinFile(self::$source, 'config.yml'); + self::$destination = self::$source; + + putenv('CECIL_DEBUG=true'); + self::$builder = Builder::create(Config::loadFile(self::$config), new PrintLogger(Builder::VERBOSITY_DEBUG)) + ->setSourceDir(self::$source) + ->setDestinationDir(self::$destination); + self::$builder->build([ + 'drafts' => true, + 'dry-run' => true, + ]); + } + + public static function tearDownAfterClass(): void + { + $fs = new Filesystem(); + $fs->remove(Util::joinFile(self::$destination, '.cecil')); + $fs->remove(Util::joinFile(self::$destination, '.cache')); + $fs->remove(Util::joinFile(self::$destination, '_site')); + } + + protected function getBuilder(): Builder + { + return self::$builder; + } + + /** + * Test that sub-section pages are created. + */ + public function testSubSectionPagesExist() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + // The "blog/tutorials" sub-section should exist as a Section-type page. + $this->assertTrue($pages->has('blog/tutorials'), 'Sub-section "blog/tutorials" should exist'); + $tutorialsPage = $pages->get('blog/tutorials'); + $this->assertEquals(Type::SECTION->value, $tutorialsPage->getType(), '"blog/tutorials" should be a section'); + + // The "blog/tutorials/advanced" sub-section should also exist. + $this->assertTrue($pages->has('blog/tutorials/advanced'), 'Sub-section "blog/tutorials/advanced" should exist'); + $advancedPage = $pages->get('blog/tutorials/advanced'); + $this->assertEquals(Type::SECTION->value, $advancedPage->getType(), '"blog/tutorials/advanced" should be a section'); + } + + /** + * Test parent/child relationships. + */ + public function testParentChildRelationships() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + $blogPage = $pages->get('blog'); + $tutorialsPage = $pages->get('blog/tutorials'); + $advancedPage = $pages->get('blog/tutorials/advanced'); + + // blog should have sub-sections + $this->assertTrue($blogPage->hasSubSections(), '"blog" should have sub-sections'); + + // tutorials should be a sub-section of blog + $this->assertTrue($tutorialsPage->isSubSection(), '"blog/tutorials" should be a sub-section'); + $this->assertTrue($tutorialsPage->hasParentSection(), '"blog/tutorials" should have a parent section'); + $this->assertEquals('blog', $tutorialsPage->getParentSection()->getId(), 'Parent of "blog/tutorials" should be "blog"'); + + // advanced should be a sub-section of tutorials + $this->assertTrue($advancedPage->isSubSection(), '"blog/tutorials/advanced" should be a sub-section'); + $this->assertEquals('blog/tutorials', $advancedPage->getParentSection()->getId()); + + // blog should NOT be a sub-section + $this->assertFalse($blogPage->isSubSection(), '"blog" should NOT be a sub-section'); + } + + /** + * Test that pages are assigned to the correct (deepest) section. + */ + public function testPagesAssignedToDeepestSection() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + $tutorialsPage = $pages->get('blog/tutorials'); + $advancedPage = $pages->get('blog/tutorials/advanced'); + + // Tutorials section should contain its direct pages + $tutorialPages = $tutorialsPage->getPages(); + $this->assertNotNull($tutorialPages, 'Tutorials section should have pages'); + $this->assertGreaterThanOrEqual(2, \count($tutorialPages), 'Tutorials section should have at least 2 pages'); + + // Advanced section should contain its direct pages + $advancedPages = $advancedPage->getPages(); + $this->assertNotNull($advancedPages, 'Advanced section should have pages'); + $this->assertGreaterThanOrEqual(1, \count($advancedPages), 'Advanced section should have at least 1 page'); + } + + /** + * Test section depth calculation. + */ + public function testSectionDepth() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + $blogPage = $pages->get('blog'); + $tutorialsPage = $pages->get('blog/tutorials'); + $advancedPage = $pages->get('blog/tutorials/advanced'); + + $this->assertEquals(0, $blogPage->getSectionDepth(), '"blog" depth should be 0'); + $this->assertEquals(1, $tutorialsPage->getSectionDepth(), '"blog/tutorials" depth should be 1'); + $this->assertEquals(2, $advancedPage->getSectionDepth(), '"blog/tutorials/advanced" depth should be 2'); + } + + /** + * Test section breadcrumb. + */ + public function testSectionBreadcrumb() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + $advancedPage = $pages->get('blog/tutorials/advanced'); + $breadcrumb = $advancedPage->getSectionBreadcrumb(); + + $this->assertCount(3, $breadcrumb, 'Breadcrumb for "blog/tutorials/advanced" should have 3 items'); + $this->assertEquals('blog', $breadcrumb[0]->getId()); + $this->assertEquals('blog/tutorials', $breadcrumb[1]->getId()); + $this->assertEquals('blog/tutorials/advanced', $breadcrumb[2]->getId()); + } + + /** + * Test getAllPagesRecursive. + */ + public function testGetAllPagesRecursive() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + $tutorialsPage = $pages->get('blog/tutorials'); + $allPages = $tutorialsPage->getAllPagesRecursive(); + + // Should include direct pages (2 tutorials) + advanced pages (1 tutorial) + $this->assertGreaterThanOrEqual(3, \count($allPages), 'Recursive pages should include sub-section pages'); + } + + /** + * Test that root section no longer contains sub-section pages. + */ + public function testRootSectionExcludesSubSectionPages() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + $blogPage = $pages->get('blog'); + $blogDirectPages = $blogPage->getPages(); + + // Blog section's direct pages should NOT include tutorial pages + if ($blogDirectPages !== null) { + foreach ($blogDirectPages as $page) { + // Check page section is "blog", not a sub-section + $this->assertEquals( + 'blog', + $page->getSection(), + \sprintf('Blog direct page "%s" should have section "blog"', $page->getId()) + ); + } + } + } + + /** + * Test that non-sub-section folders without index.md are not treated as sub-sections. + * (backward compatibility) + */ + public function testFoldersWithoutIndexAreNotSubSections() + { + $builder = $this->getBuilder(); + $pages = $builder->getPages(); + + // The "assets" folder under Blog should NOT create a sub-section + // (there's no blog/assets/index.md) + $this->assertFalse($pages->has('blog/assets'), 'Folders without index.md should NOT become sub-sections'); + } +} diff --git a/tests/fixtures/website/pages/Blog/Tutorials/Advanced/Tutorial 3.md b/tests/fixtures/website/pages/Blog/Tutorials/Advanced/Tutorial 3.md new file mode 100644 index 000000000..db7ae2532 --- /dev/null +++ b/tests/fixtures/website/pages/Blog/Tutorials/Advanced/Tutorial 3.md @@ -0,0 +1,5 @@ +--- +title: Advanced Generics Tutorial +date: 2025-03-10 +--- +An advanced tutorial about generics. diff --git a/tests/fixtures/website/pages/Blog/Tutorials/Advanced/index.md b/tests/fixtures/website/pages/Blog/Tutorials/Advanced/index.md new file mode 100644 index 000000000..5b8ebdc99 --- /dev/null +++ b/tests/fixtures/website/pages/Blog/Tutorials/Advanced/index.md @@ -0,0 +1,5 @@ +--- +title: Advanced Tutorials +date: 2025-03-01 +--- +Section of advanced tutorials. diff --git a/tests/fixtures/website/pages/Blog/Tutorials/Tutorial 1.md b/tests/fixtures/website/pages/Blog/Tutorials/Tutorial 1.md new file mode 100644 index 000000000..4a89ac15c --- /dev/null +++ b/tests/fixtures/website/pages/Blog/Tutorials/Tutorial 1.md @@ -0,0 +1,6 @@ +--- +title: PHP Tutorial +date: 2025-02-01 +tags: [Tag 1] +--- +A tutorial about PHP. diff --git a/tests/fixtures/website/pages/Blog/Tutorials/Tutorial 2.md b/tests/fixtures/website/pages/Blog/Tutorials/Tutorial 2.md new file mode 100644 index 000000000..5749ce7d7 --- /dev/null +++ b/tests/fixtures/website/pages/Blog/Tutorials/Tutorial 2.md @@ -0,0 +1,6 @@ +--- +title: JavaScript Tutorial +date: 2025-02-15 +tags: [Tag 2] +--- +A tutorial about JavaScript. diff --git a/tests/fixtures/website/pages/Blog/Tutorials/index.md b/tests/fixtures/website/pages/Blog/Tutorials/index.md new file mode 100644 index 000000000..2bdf5607e --- /dev/null +++ b/tests/fixtures/website/pages/Blog/Tutorials/index.md @@ -0,0 +1,5 @@ +--- +title: Tutorials +date: 2025-01-15 +--- +Section of tutorials.