From 2747b334edb8f362dc531dd2ade549eff7c2fffa Mon Sep 17 00:00:00 2001 From: Roland Tfirst Date: Sat, 25 Apr 2026 11:57:03 +0200 Subject: [PATCH 1/3] [BUGFIX] Include pages under spacer pages (#2) in llms.txt, bump to 1.0.12 Spacer pages (doktype 199) were filtered out by the SQL query in PageTreeService::collectPages(), which also suppressed all of their descendants because the recursion only fires for rows returned by the query. Spacers are now traversed for recursion but skipped from the output array, mirroring the TYPO3 frontend menu behavior. Also adds functional test coverage for PageTreeService (spacer traversal, nested spacers, explicit page exclusion) and wires a new Functional Tests job into CI matching the unit-tests matrix. Fixes #2 --- .github/workflows/ci.yaml | 53 ++++++++++++ CHANGELOG.md | 10 +++ Classes/Service/PageTreeService.php | 15 +++- Documentation/guides.xml | 2 +- Tests/Functional/Fixtures/Pages.csv | 10 +++ .../Service/PageTreeServiceTest.php | 85 +++++++++++++++++++ ext_emconf.php | 2 +- phpunit.functional.xml | 25 ++++++ 8 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 Tests/Functional/Fixtures/Pages.csv create mode 100644 Tests/Functional/Service/PageTreeServiceTest.php create mode 100644 phpunit.functional.xml 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/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..064e876 --- /dev/null +++ b/phpunit.functional.xml @@ -0,0 +1,25 @@ + + + + + Tests/Functional + + + + + + + + From 01c39e5c74da42e7592732c1ed4bd1bbbee92d13 Mon Sep 17 00:00:00 2001 From: Roland Tfirst Date: Sat, 25 Apr 2026 12:08:07 +0200 Subject: [PATCH 2/3] [BUGFIX] Resolve testing-framework bootstrap path for CI Local DDEV has the composer vendor/ two levels above the extension, while CI installs vendor/ directly under the extension root. Use a small wrapper bootstrap that locates the autoloader via known candidate paths and then loads the testing-framework bootstrap by reflecting on Testbase's file location. --- Tests/Functional/bootstrap.php | 27 +++++++++++++++++++++++++++ phpunit.functional.xml | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 Tests/Functional/bootstrap.php diff --git a/Tests/Functional/bootstrap.php b/Tests/Functional/bootstrap.php new file mode 100644 index 0000000..99d611c --- /dev/null +++ b/Tests/Functional/bootstrap.php @@ -0,0 +1,27 @@ +getFileName(), 3) + . '/Resources/Core/Build/FunctionalTestsBootstrap.php'; + +require $frameworkBootstrap; diff --git a/phpunit.functional.xml b/phpunit.functional.xml index 064e876..f93b82e 100644 --- a/phpunit.functional.xml +++ b/phpunit.functional.xml @@ -1,7 +1,7 @@ Date: Sat, 25 Apr 2026 12:12:09 +0200 Subject: [PATCH 3/3] [BUGFIX] Use FQ \dirname() in functional bootstrap for php-cs-fixer --- Tests/Functional/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Functional/bootstrap.php b/Tests/Functional/bootstrap.php index 99d611c..59a3963 100644 --- a/Tests/Functional/bootstrap.php +++ b/Tests/Functional/bootstrap.php @@ -21,7 +21,7 @@ exit(1); } -$frameworkBootstrap = dirname((string)(new ReflectionClass(\TYPO3\TestingFramework\Core\Testbase::class))->getFileName(), 3) +$frameworkBootstrap = \dirname((string)(new ReflectionClass(\TYPO3\TestingFramework\Core\Testbase::class))->getFileName(), 3) . '/Resources/Core/Build/FunctionalTestsBootstrap.php'; require $frameworkBootstrap;