diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 485b2f7..98e8466 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -150,3 +150,56 @@ jobs:
- name: Run Unit Tests
run: vendor/bin/phpunit --configuration=phpunit.xml
+
+ functional-tests:
+ name: Functional Tests
+ runs-on: ubuntu-latest
+ needs: [phpstan, rector]
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ['8.2', '8.3', '8.4']
+ typo3-version: ['^13.4', '^14.0']
+ exclude:
+ # TYPO3 14 requires PHP 8.3+
+ - php-version: '8.2'
+ typo3-version: '^14.0'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: intl, pdo_sqlite, sqlite3
+ coverage: none
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v5
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-functional-${{ matrix.php-version }}-${{ matrix.typo3-version }}-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-functional-${{ matrix.php-version }}-${{ matrix.typo3-version }}-
+ ${{ runner.os }}-composer-
+
+ - name: Remove incompatible dev dependencies for TYPO3 14
+ if: matrix.typo3-version == '^14.0'
+ run: |
+ composer remove --dev saschaegerer/phpstan-typo3 --no-interaction --no-update
+
+ - name: Install dependencies
+ run: |
+ composer require typo3/cms-core:${{ matrix.typo3-version }} typo3/cms-frontend:${{ matrix.typo3-version }} --no-progress --no-interaction
+ composer install --no-progress --no-interaction
+
+ - name: Run Functional Tests
+ env:
+ typo3DatabaseDriver: pdo_sqlite
+ typo3DatabaseName: typo3_test
+ run: vendor/bin/phpunit --configuration=phpunit.functional.xml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf7c50d..64cc937 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to the rt_llms_txt extension will be documented in this file
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.0.12] - 2026-04-25
+
+### Fixed
+
+- **Bug:** Pages located below a "Spacer" page (`doktype = 199`, "Trennzeichen für Menü") are now included in `llms.txt`. Previously the spacer row was filtered out by the SQL query, which suppressed all of its descendants (issue [#2](https://github.com/rtfirst/llms-txt/issues/2)). Spacers themselves remain excluded from the output, matching the TYPO3 frontend menu behavior.
+
+### Added
+
+- Functional test coverage for `PageTreeService` (spacer traversal, nested spacers, explicit page exclusion).
+
## [1.0.11] - 2026-03-22
### Added
diff --git a/Classes/Service/PageTreeService.php b/Classes/Service/PageTreeService.php
index ff1f3cd..1493b23 100644
--- a/Classes/Service/PageTreeService.php
+++ b/Classes/Service/PageTreeService.php
@@ -121,12 +121,14 @@ private function collectPages(
$queryBuilder->expr()->eq('tx_llmstxt_exclude', 0), // Exclude pages marked for exclusion
];
- // Exclude certain doktypes (folders, recycler, etc.)
- // doktype values: 254=sysfolder, 255=recycler, 199=spacer, 6=be_user_section
+ // Exclude doktypes that never produce frontend output and have no
+ // visible descendants (folders, recycler, BE user section).
+ // Spacers (199) are intentionally NOT excluded here: they may have
+ // child pages that should appear in llms.txt. The spacer row itself
+ // is filtered out below.
$excludedDoktypes = [
255, // Recycler
PageRepository::DOKTYPE_SYSFOLDER,
- PageRepository::DOKTYPE_SPACER,
PageRepository::DOKTYPE_BE_USER_SECTION,
];
$constraints[] = $queryBuilder->expr()->notIn(
@@ -152,6 +154,13 @@ private function collectPages(
while ($row = $result->fetchAssociative()) {
$pageUid = (int)$row['uid'];
+ // Spacer pages are menu separators and are not output themselves,
+ // but their child pages must still be discovered.
+ if ((int)$row['doktype'] === PageRepository::DOKTYPE_SPACER) {
+ $this->collectPages($pageUid, $languageId, $excludePageUids, $includeHidden, $pages, $language);
+ continue;
+ }
+
// Get translated page if not default language
if ($languageId > 0) {
$translatedPage = $this->getTranslatedPage($pageUid, $languageId, $includeHidden);
diff --git a/Documentation/guides.xml b/Documentation/guides.xml
index 462b1d5..3ffbc77 100644
--- a/Documentation/guides.xml
+++ b/Documentation/guides.xml
@@ -8,7 +8,7 @@
>
importCSVDataSet(__DIR__ . '/../Fixtures/Pages.csv');
+ }
+
+ #[Test]
+ public function pagesUnderSpacerAreIncluded(): void
+ {
+ $service = $this->get(PageTreeService::class);
+ \assert($service instanceof PageTreeService);
+
+ $site = $this->createSite();
+ $pages = $service->getPages($site, $site->getDefaultLanguage());
+
+ $uids = array_keys($pages);
+ sort($uids);
+
+ // Spacer (3, 6) absent; their descendants (4, 5, 7) present.
+ // Excluded page (8) absent.
+ self::assertSame([1, 2, 4, 5, 7], $uids);
+ }
+
+ #[Test]
+ public function spacerPageItselfIsNotIncluded(): void
+ {
+ $service = $this->get(PageTreeService::class);
+ \assert($service instanceof PageTreeService);
+
+ $site = $this->createSite();
+ $pages = $service->getPages($site, $site->getDefaultLanguage());
+
+ self::assertArrayNotHasKey(3, $pages, 'Spacer page must not appear in output');
+ self::assertArrayNotHasKey(6, $pages, 'Nested spacer page must not appear in output');
+ }
+
+ #[Test]
+ public function explicitlyExcludedPageIsRespected(): void
+ {
+ $service = $this->get(PageTreeService::class);
+ \assert($service instanceof PageTreeService);
+
+ $site = $this->createSite();
+ $pages = $service->getPages($site, $site->getDefaultLanguage(), [2]);
+
+ self::assertArrayNotHasKey(2, $pages);
+ self::assertArrayHasKey(4, $pages, 'Children of spacer remain when an unrelated page is excluded');
+ }
+
+ private function createSite(): Site
+ {
+ return new Site('test', 1, [
+ 'base' => 'https://example.com/',
+ 'languages' => [
+ [
+ 'languageId' => 0,
+ 'title' => 'Default',
+ 'locale' => 'en_US.UTF-8',
+ 'base' => '/',
+ ],
+ ],
+ ]);
+ }
+}
diff --git a/Tests/Functional/bootstrap.php b/Tests/Functional/bootstrap.php
new file mode 100644
index 0000000..59a3963
--- /dev/null
+++ b/Tests/Functional/bootstrap.php
@@ -0,0 +1,27 @@
+getFileName(), 3)
+ . '/Resources/Core/Build/FunctionalTestsBootstrap.php';
+
+require $frameworkBootstrap;
diff --git a/ext_emconf.php b/ext_emconf.php
index 0cbb076..fe23263 100644
--- a/ext_emconf.php
+++ b/ext_emconf.php
@@ -7,7 +7,7 @@
'author' => 'Roland Tfirst',
'author_email' => 'roland@tfirst.de',
'state' => 'stable',
- 'version' => '1.0.11',
+ 'version' => '1.0.12',
'constraints' => [
'depends' => [
'typo3' => '13.0.0-14.99.99',
diff --git a/phpunit.functional.xml b/phpunit.functional.xml
new file mode 100644
index 0000000..f93b82e
--- /dev/null
+++ b/phpunit.functional.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Tests/Functional
+
+
+
+
+
+
+
+