---
title: "Developer"
manual: "LLMs.txt & Markdown for AI"
version: "1.1"
permalink: "https://docs.typo3.org/permalink/woit/si-llms-txt:developer@1.1"
source: "Developer/Index.rst"
rendered: "2026-10-26T14:58:51+00:00"
---

# Developer {#developer}

## Request flow {#developer-request-flow}

The extension adds three PSR-15 middlewares to the frontend stack. No route
enhancer is involved: the middlewares answer the text files themselves and
remove `.md` before TYPO3 routes the request.

```text
request
  ├─ woit/si-llms-txt/markdown-suffix   before typo3/cms-frontend/site
  ├─   typo3/cms-frontend/site
  ├─ woit/si-llms-txt/llms-txt          before typo3/cms-frontend/base-redirect-resolver
  ├─   … page-resolver, prepare-tsfe-rendering, shortcut redirect
  ├─ woit/si-llms-txt/markdown-render   after prepare-tsfe-rendering
  └─   rendering
```

| Middleware | Task |
| --- | --- |
| `MarkdownSuffixMiddleware` | `/de/page.md` → routes `/de/page` and sets the request attribute `sillmstxt.markdown`. Responses without Markdown become a plain 404; redirects keep the `.md` suffix. |
| `LlmsTxtMiddleware` | Answers `llms.txt` / `llms-full.txt` for the site root and every language base. |
| `MarkdownRenderMiddleware` | Uses the page field "LLM Markdown" or lets the page render and converts the HTML. For the EXT:news detail page it takes the news record (see [EXT:news (tx_news)](https://docs.typo3.org/permalink/woit/si-llms-txt:news@1.1)). |

For normal HTML requests each middleware only compares a string and passes
the request on.

The event listener `AddMarkdownAlternateLink`
(`AfterCacheableContentIsGeneratedEvent`) adds the `<link rel="alternate">`
and `<link rel="describedby">` tags when a page is rendered; the result is
part of the page cache. The HTTP `Link` header with the same relations is
added by `MarkdownRenderMiddleware` on every request (see
[Discovery links](https://docs.typo3.org/permalink/woit/si-llms-txt:endpoints-discovery@1.1)).

## Classes {#developer-classes}

| Class | Purpose |
| --- | --- |
| `Service\LlmsTxtBuilder` | Builds `llms.txt`, `llms-full.txt` and the frontmatter of page and news documents; caching |
| `Service\PageCollector` | Walks the page tree, language overlay (strict) |
| `Service\NewsCollector` | News records of EXT:news (optional) |
| `Service\MarkdownConverter` | HTML → Markdown with league/html-to-markdown; removes navigation, breadcrumbs, asides, forms, buttons, scripts, decorative images and links without text; accordion titles and `<summary>` become h3 headings; keeps button titles in headings; resolves lazy-loaded images; moves card links onto their heading; makes links absolute |
| `Service\DiscoveryLinks` | `alternate` and `describedby` links of a page, as HTML tags or HTTP `Link` header |
| `Service\MarkdownUrl` | Page URL ↔ `.md` URL |
| `Service\SiteConfig` | Site set active? Settings, included page types |

## PSR-14 events {#developer-events}

Listeners of other extensions can change what the extension generates, for
example list all records of a plugin that shows only the first result page.

### ModifyLlmsTxtEvent {#modifyllmstxtevent}

`Woit\SiLlmsTxt\Event\ModifyLlmsTxtEvent` is dispatched before
`llms.txt` or `llms-full.txt` of a site language is built. It
only runs on a cache miss.

-   `getSite()`, `getLanguage()`, `getFileName()`
-   `getTitle()` / `setTitle()`, `getIntro()` / `setIntro()`
-   `getSections()` / `setSections()`, `getSection($id)`,
    `addSection($id, $section, $before)`, `removeSection($id)`
-   `addItem($sectionId, $item)`: a missing section is created at the end
    with the id as title
-   `addCacheTags(...$tags)`, `setCacheLifetime($seconds)`

Section ids: `pages`, `section-<uid>` (top-level page), `articles`,
`news`. The sections "Languages" and "Optional" are added afterwards.

A `Woit\SiLlmsTxt\LlmsTxt\Section` has `title` and `items`. An
`Woit\SiLlmsTxt\LlmsTxt\Item` has `title`, `url`, `description`,
`date`, `includeInFull` and `markdown`. An item goes into
`llms-full.txt` with its `markdown` if set, otherwise only with
`includeInFull`: then `url` must be a `.md` URL of the same site and
is fetched like a page.

**EXT:my_ext/Classes/EventListener/AddRecordsToLlmsTxt.php**

```php
use TYPO3\CMS\Core\Attribute\AsEventListener;
use Woit\SiLlmsTxt\Event\ModifyLlmsTxtEvent;
use Woit\SiLlmsTxt\LlmsTxt\Item;
use Woit\SiLlmsTxt\LlmsTxt\Section;

final class AddRecordsToLlmsTxt
{
    #[AsEventListener]
    public function __invoke(ModifyLlmsTxtEvent $event): void
    {
        $event->addSection('records', new Section('Records'), 'news');
        foreach ($this->repository->findAll() as $record) {
            $event->addItem('records', new Item(
                $record->getTitle(),
                $record->getUrl(),
                $record->getTeaser(),
            ));
        }
        $event->addCacheTags('tx_myext_record');
    }
}
```

### ModifyMarkdownEvent {#modifymarkdownevent}

`Woit\SiLlmsTxt\Event\ModifyMarkdownEvent` is dispatched for every
`.md` response of a page or EXT:news record.

-   `getRequest()`, `getSite()`, `getLanguage()`
-   `getTable()` (`pages` or `tx_news_domain_model_news`),
    `getRecord()`
-   `getFrontmatter()` / `setFrontmatter()`: `title`,
    `description`, `language`, `date`, `lastmod`, `canonical`,
    `source`, `generator`; empty values are not written
-   `getMarkdown()` / `setMarkdown()`: body without frontmatter
-   `getHtml()`: rendered page, `null` for the field "LLM Markdown"
-   `setNotFound()`: answer 404

A `.md` blocked with `setNotFound()` is still listed in
`llms.txt`; remove it there with `ModifyLlmsTxtEvent`.

Example: list all records on a list page that renders only the first result
page.

**EXT:my_ext/Classes/EventListener/ListAllRecordsInMarkdown.php**

```php
use TYPO3\CMS\Core\Attribute\AsEventListener;
use Woit\SiLlmsTxt\Event\ModifyMarkdownEvent;

final class ListAllRecordsInMarkdown
{
    private const LIST_PAGE_UID = 42;

    #[AsEventListener]
    public function __invoke(ModifyMarkdownEvent $event): void
    {
        if ($event->getTable() !== 'pages' || (int)($event->getRecord()['l10n_parent'] ?: $event->getRecord()['uid']) !== self::LIST_PAGE_UID) {
            return;
        }
        $lines = [];
        foreach ($this->repository->findAll() as $record) {
            $lines[] = '- [' . $record->getTitle() . '](' . $record->getUrl() . '): ' . $record->getTeaser();
        }
        $event->setMarkdown($event->getMarkdown() . "\n\n## All records\n\n" . implode("\n", $lines));
    }
}
```

Example: describe the record shown on a detail page, answer 404 without a
record.

**EXT:my_ext/Classes/EventListener/DescribeDetailRecord.php**

```php
use TYPO3\CMS\Core\Attribute\AsEventListener;
use Woit\SiLlmsTxt\Event\ModifyMarkdownEvent;

final class DescribeDetailRecord
{
    private const DETAIL_PAGE_UID = 43;

    #[AsEventListener]
    public function __invoke(ModifyMarkdownEvent $event): void
    {
        if ($event->getTable() !== 'pages' || (int)($event->getRecord()['l10n_parent'] ?: $event->getRecord()['uid']) !== self::DETAIL_PAGE_UID) {
            return;
        }
        $arguments = $event->getRequest()->getAttribute('routing')->getArguments();
        $record = $this->repository->findByUid((int)($arguments['tx_myext_pi1']['record'] ?? 0));
        if ($record === null) {
            $event->setNotFound();
            return;
        }
        $frontmatter = $event->getFrontmatter();
        $frontmatter['title'] = $record->getTitle();
        $frontmatter['description'] = $record->getTeaser();
        $frontmatter['canonical'] = $record->getUrl();
        $event->setFrontmatter($frontmatter);
    }
}
```

## Caching {#developer-caching}

-   Cache `sillmstxt` (database backend, group `pages`)
-   Entries for `llms.txt` and `llms-full.txt` per site language
-   Tags: `pageId_<uid>` of every listed page and the root page;
    with EXT:news also `tx_news`, `tx_news_pid_<pid>`, `tx_news_uid_<uid>`;
    plus the tags of `ModifyLlmsTxtEvent` listeners
-   The intro of the language is part of the cache identifier
-   `llms-full.txt` with pages that could not be fetched is kept for
    5 minutes only
-   `.md` documents are not cached separately: the HTML comes from the page
    cache, only the conversion runs per request

## Database {#developer-database}

**ext_tables.sql**

```sql
CREATE TABLE pages (
    tx_sillmstxt_exclude smallint(5) unsigned DEFAULT '0' NOT NULL,
    tx_sillmstxt_priority int(11) DEFAULT '0' NOT NULL,
    tx_sillmstxt_description text,
    tx_sillmstxt_markdown mediumtext
);
```

`tx_sillmstxt_exclude` and `tx_sillmstxt_priority` use
`l10n_mode = exclude` (same value in all languages).
