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 + + + + + + + +