Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions Classes/Service/PageTreeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion Documentation/guides.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
>
<project
title="LLMs.txt Generator"
release="1.0.11"
release="1.0.12"
copyright="Roland Tfirst"
/>
<extension
Expand Down
10 changes: 10 additions & 0 deletions Tests/Functional/Fixtures/Pages.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"pages"
,"uid","pid","sys_language_uid","l10n_parent","title","doktype","slug","sorting","hidden","deleted","tx_llmstxt_exclude"
,1,0,0,0,"Root",1,"/",1,0,0,0
,2,1,0,0,"Normal child",1,"/normal-child",2,0,0,0
,3,1,0,0,"Spacer in menu",199,"/spacer",3,0,0,0
,4,3,0,0,"Child of spacer",1,"/spacer/child-of-spacer",1,0,0,0
,5,4,0,0,"Grandchild via spacer",1,"/spacer/child-of-spacer/grandchild",1,0,0,0
,6,3,0,0,"Nested spacer",199,"/spacer/nested-spacer",2,0,0,0
,7,6,0,0,"Deep child via nested spacers",1,"/spacer/nested-spacer/deep-child",1,0,0,0
,8,1,0,0,"Excluded page",1,"/excluded",4,0,0,1
85 changes: 85 additions & 0 deletions Tests/Functional/Service/PageTreeServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace RTfirst\LlmsTxt\Tests\Functional\Service;

use PHPUnit\Framework\Attributes\Test;
use RTfirst\LlmsTxt\Service\PageTreeService;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;

final class PageTreeServiceTest extends FunctionalTestCase
{
protected array $coreExtensionsToLoad = [
'frontend',
];

protected array $testExtensionsToLoad = [
'rtfirst/llms-txt',
];

protected function setUp(): void
{
parent::setUp();
$this->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' => '/',
],
],
]);
}
}
27 changes: 27 additions & 0 deletions Tests/Functional/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

$autoloadCandidates = [
__DIR__ . '/../../vendor/autoload.php',
__DIR__ . '/../../../../vendor/autoload.php',
__DIR__ . '/../../../../../../vendor/autoload.php',
];

$autoloader = null;
foreach ($autoloadCandidates as $candidate) {
if (file_exists($candidate)) {
$autoloader = require $candidate;
break;
}
}

if ($autoloader === null) {
fwrite(STDERR, "Composer autoloader not found.\n");
exit(1);
}

$frameworkBootstrap = \dirname((string)(new ReflectionClass(\TYPO3\TestingFramework\Core\Testbase::class))->getFileName(), 3)
. '/Resources/Core/Build/FunctionalTestsBootstrap.php';

require $frameworkBootstrap;
2 changes: 1 addition & 1 deletion ext_emconf.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 25 additions & 0 deletions phpunit.functional.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="Tests/Functional/bootstrap.php"
cacheDirectory=".phpunit.cache"
cacheResult="false"
colors="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Functional">
<directory>Tests/Functional</directory>
</testsuite>
</testsuites>
<php>
<env name="typo3DatabaseDriver" value="pdo_sqlite"/>
<env name="typo3DatabaseName" value="typo3_test"/>
<ini name="memory_limit" value="512M"/>
</php>
</phpunit>