Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module.exports = {
items:
[
{text: 'Robots.txt', link: '/robots'},
{text: 'LLMS.txt', link: '/llms'},
{text: 'Sitemap.xml', link: '/sitemap'},
{text: 'Redirects', link: '/redirects'},
{text: '404 tracking', link: '/notfound'},
Expand Down
86 changes: 86 additions & 0 deletions docs/llms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
title: LLM.txt - SEO Fields
prev: false
next: false
---

# LLM.txt

### What is llms.txt?
[llms.txt](https://llmstxt.org/) is a standard for providing structured, markdown-formatted information about your website for large language models. It helps AI tools understand your site's content and structure in a concise, machine-readable way.

### Enabling
You can enable llms.txt from the control panel under **SEO > LLM.txt**. Toggle the "Enable llms.txt" switch to have the plugin handle your `/llms.txt` route.

### Title & Summary
Two optional fields let you customize the top of your llms.txt output:

- **Title** — The main heading for your llms.txt file. Defaults to the site name if left empty.
- **Summary** — A brief description of what the website is about. This is rendered as a blockquote in the output.

### Description Fallback Fields
For each entry type, you can select a field to use as a fallback description when no SEO meta description is set on an entry. Only **Plain Text** and **CKEditor** fields are available as options.

The description priority for each entry is:
1. SEO meta description (if set)
2. The configured fallback field for that entry type
3. No description

### Generated Content
The plugin automatically builds the llms.txt content based on your site's sections and category groups:

- **Singles** — Listed under an "Overview" heading.
- **Channels** — Each channel gets its own heading showing the field types it contains, the total entry count, and up to 5 most recent entries.
- **Structures** — Each structure gets its own heading with entries displayed in their full hierarchy using nested lists.
- **Categories** — Grouped under a "Categories" heading, each group showing its count and up to 5 example categories.

Sections and category groups are **skipped** if they don't have URLs enabled for the current site or have zero entries.

### Entry Format
Each entry is rendered as a markdown list item following the llms.txt spec:

```
- [Entry Title](https://example.com/entry-url): Description text
```

The title is taken from the SEO meta title if set, otherwise the entry title. The description follows the priority described above.

### Caching
The generated llms.txt output is cached automatically. The cache is invalidated when entries are saved or deleted, and when sections or entry types change. You can also manually clear it from Craft's **Utilities > Clear Caches** tool.

### Multisite
Each site gets its own llms.txt settings and output. Use the site switcher at the top of the LLM.txt settings page to configure each site independently.

### Example Output
```markdown
# My Website

> A brief description of the website.

## Overview

- [Home](https://example.com): Welcome to our website
- [About](https://example.com/about): Learn more about us

## Blog

Contains: Plain Text, CKEditor
5 entries

- [Latest Post](https://example.com/blog/latest-post): Summary of the post
- [Another Post](https://example.com/blog/another-post): Another summary

## Pages

- [Services](https://example.com/services): Our services
- [Web Development](https://example.com/services/web-development): Custom web solutions
- [Design](https://example.com/services/design): Creative design services

## Categories

### Topics (3)

- [Technology](https://example.com/topics/technology)
- [Design](https://example.com/topics/design)
- [Business](https://example.com/topics/business)
```
38 changes: 35 additions & 3 deletions src/SeoFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
use studioespresso\seofields\models\Settings;
use studioespresso\seofields\records\NotFoundRecord;
use studioespresso\seofields\services\DefaultsService;
use studioespresso\seofields\services\LlmService;
use studioespresso\seofields\services\NotFoundService;
use studioespresso\seofields\services\RedirectService;
use studioespresso\seofields\services\RenderService;
Expand All @@ -72,6 +73,7 @@
* @property RedirectService $redirectService
* @property NotFoundService $notFoundService
* @property SchemaService $schemaService
* @property LlmService $llmService
* @method Settings getSettings()
*/
class SeoFields extends Plugin
Expand All @@ -89,7 +91,7 @@ class SeoFields extends Plugin

// Public Properties
// =========================================================================
public string $schemaVersion = "4.0.0";
public string $schemaVersion = "5.0.0";


public const EVENT_SEOFIELDS_REGISTER_ELEMENT = "registerSeoElement";
Expand All @@ -108,6 +110,7 @@ public function init()
"redirectService" => RedirectService::class,
"notFoundService" => NotFoundService::class,
"schemaService" => SchemaService::class,
"llmService" => LlmService::class,
]);

if (Craft::$app instanceof ConsoleApplication) {
Expand Down Expand Up @@ -168,6 +171,12 @@ public function getCpNavItem(): ?array
'url' => 'seo-fields/robots',
];
}
if ($currentUser->can('seo-fields:llm')) {
$subNavs['llm'] = [
'label' => 'LLM.txt',
'url' => 'seo-fields/llm',
];
}
if ($currentUser->can('seo-fields:sitemap')) {
$subNavs['sitemap'] = [
'label' => 'Sitemap.xml',
Expand Down Expand Up @@ -242,6 +251,9 @@ function(RegisterUserPermissionsEvent $event) {
'seo-fields:robots' => [
'label' => Craft::t('seo-fields', 'Robots'),
],
'seo-fields:llm' => [
'label' => Craft::t('seo-fields', 'LLM.txt'),
],
'seo-fields:sitemap' => [
'label' => Craft::t('seo-fields', 'Sitemap'),
],
Expand Down Expand Up @@ -272,6 +284,9 @@ function(RegisterUrlRulesEvent $event) {
'robots.txt' => 'seo-fields/robots/render',
]);
}
$event->rules = array_merge($event->rules, [
'llms.txt' => 'seo-fields/llm/render',
]);
if (SeoFields::$plugin->getSettings()->sitemapPerSite) {
$shouldRender = SeoFields::getInstance()->sitemapService->shouldRenderBySiteId(Craft::$app->getSites()->getCurrentSite());
} else {
Expand All @@ -298,11 +313,11 @@ function(RegisterUrlRulesEvent $event) {
'seo-fields' => 'seo-fields/defaults/index',
'seo-fields/cp-api/<action>' => 'seo-fields/cp-api/<action>',
'seo-fields/<controller:(not-found)>/<siteHandle:{handle}>' => 'seo-fields/<controller>/index',
'seo-fields/<controller:(defaults|robots|sitemap|not-found|redirects|schema)>' => 'seo-fields/<controller>/index',
'seo-fields/<controller>' => 'seo-fields/<controller>/index',
'seo-fields/<controller:(redirects)>/<id:\d+>' => 'seo-fields/<controller>/<action>',
'seo-fields/<controller:(redirects|not-found)>/<action>' => 'seo-fields/<controller>/<action>',
'seo-fields/<controller:(redirects|not-found)>/<action>/<id:\d+>' => 'seo-fields/<controller>/<action>',
'seo-fields/<controller:(defaults|robots|sitemap|schema)>/<siteHandle:{handle}>' => 'seo-fields/<controller>/settings',
'seo-fields/<controller:(defaults|robots|sitemap|schema|llm)>/<siteHandle:{handle}>' => 'seo-fields/<controller>/settings',
]);
}
);
Expand All @@ -325,6 +340,7 @@ function(SiteEvent $event) {
Elements::EVENT_AFTER_SAVE_ELEMENT,
function(ElementEvent $event) {
SeoFields::$plugin->sitemapService->clearCacheForElement($event->element);
SeoFields::$plugin->llmService->clearCaches();
}
);

Expand All @@ -333,6 +349,15 @@ function(ElementEvent $event) {
Elements::EVENT_AFTER_DELETE_ELEMENT,
function(ElementEvent $event) {
SeoFields::$plugin->sitemapService->clearCacheForElement($event->element);
SeoFields::$plugin->llmService->clearCaches();
}
);

Event::on(
Entries::class,
Entries::EVENT_AFTER_SAVE_SECTION,
function(SectionEvent $event) {
SeoFields::$plugin->llmService->clearCaches();
}
);

Expand All @@ -341,6 +366,7 @@ function(ElementEvent $event) {
Entries::EVENT_AFTER_DELETE_SECTION,
function(SectionEvent $event) {
SeoFields::$plugin->sitemapService->clearCaches();
SeoFields::$plugin->llmService->clearCaches();
}
);

Expand All @@ -349,6 +375,7 @@ function(SectionEvent $event) {
Entries::EVENT_AFTER_DELETE_ENTRY_TYPE,
function(EntryTypeEvent $event) {
SeoFields::$plugin->sitemapService->clearCaches();
SeoFields::$plugin->llmService->clearCaches();
}
);

Expand Down Expand Up @@ -461,6 +488,11 @@ function(RegisterCacheOptionsEvent $event) {
"label" => "Sitemap caches (SEO Fields)",
"action" => [SeoFields::$plugin->sitemapService, 'clearCaches'],
],
[
"key" => 'seofields_llm',
"label" => "LLM.txt caches (SEO Fields)",
"action" => [SeoFields::$plugin->llmService, 'clearCaches'],
],
]
);
}
Expand Down
129 changes: 129 additions & 0 deletions src/controllers/LlmController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace studioespresso\seofields\controllers;

use Craft;
use craft\helpers\Cp;
use craft\helpers\Json;
use craft\models\Site;
use craft\web\Controller;
use studioespresso\seofields\models\SeoDefaultsModel;
use studioespresso\seofields\SeoFields;

class LlmController extends Controller
{
protected array|bool|int $allowAnonymous = ['render'];

public Site|null $site = null;

public function init(): void
{
if (Craft::$app->getRequest()->getQueryParam('site')) {
$this->site = Craft::$app->getSites()->getSiteByHandle(Craft::$app->getRequest()->getQueryParam('site'));
} else {
$this->site = Craft::$app->getSites()->getPrimarySite();
}
parent::init();
}

public function actionIndex()
{
$sites = Craft::$app->getSites()->getEditableSites();
$data = SeoFields::$plugin->defaultsService->getDataBySiteHandle($this->site->handle);

$crumbs = ['label' => $this->site->name];
if (Craft::$app->getIsMultiSite()) {
$crumbs['menu'] = [
'label' => Craft::t('site', 'Select site'),
'items' => Cp::siteMenuItems($sites, $this->site),
];
}

$llmData = [];
if ($data->llm) {
$llmData = is_array($data->llm) ? $data->llm : Json::decodeIfJson($data->llm) ?? [];
}

// Build sections with entry types and their text fields for the description fallback config
$sectionsData = [];
foreach (Craft::$app->getEntries()->getAllSections() as $section) {
$siteSettings = $section->getSiteSettings();
if (!isset($siteSettings[$this->site->id]) || !$siteSettings[$this->site->id]->hasUrls) {
continue;
}

$entryTypes = [];
foreach ($section->getEntryTypes() as $entryType) {
$fields = [];
foreach ($entryType->getFieldLayout()->getCustomFields() as $field) {
if (!($field instanceof \craft\fields\PlainText) && !($field instanceof \craft\ckeditor\Field)) {

Check failure on line 59 in src/controllers/LlmController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Class craft\ckeditor\Field not found.
continue;
}
$fields[] = [
'handle' => $field->handle,

Check failure on line 63 in src/controllers/LlmController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Access to property $handle on an unknown class craft\ckeditor\Field.
'name' => $field->name,

Check failure on line 64 in src/controllers/LlmController.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

Access to property $name on an unknown class craft\ckeditor\Field.
];
}
$entryTypes[] = [
'id' => $entryType->id,
'name' => $entryType->name,
'fields' => $fields,
];
}

$sectionsData[] = [
'name' => $section->name,
'entryTypes' => $entryTypes,
];
}

return $this->asCpScreen()
->selectedSubnavItem('llm')
->title(Craft::t('seo-fields', 'LLM.txt'))
->crumbs([$crumbs])
->action('seo-fields/llm/save')
->contentTemplate('seo-fields/_llm/_content', [
'data' => $data,
'llmData' => $llmData,
'site' => $this->site,
'sectionsData' => $sectionsData,
]);
}

public function actionSave()
{
$data = [];
if (Craft::$app->getRequest()->getBodyParam('id')) {
$model = SeoFields::$plugin->defaultsService->getDataById(Craft::$app->getRequest()->getBodyParam('id'));
} else {
$model = new SeoDefaultsModel();
}
$data['enableLlm'] = Craft::$app->getRequest()->getBodyParam('enableLlm');
$data['llm'] = Json::encode([
'title' => Craft::$app->getRequest()->getBodyParam('llmTitle'),
'summary' => Craft::$app->getRequest()->getBodyParam('llmSummary'),
'descriptionFields' => Craft::$app->getRequest()->getBodyParam('descriptionFields', []),
]);
$data['siteId'] = Craft::$app->getRequest()->getBodyParam('siteId', Craft::$app->getSites()->getPrimarySite()->id);
$model->setAttributes($data);
SeoFields::$plugin->defaultsService->saveDefaults($model, Craft::$app->sites->currentSite->id);
SeoFields::$plugin->llmService->clearCaches();
}

public function actionRender(): \yii\web\Response
{
$site = Craft::$app->getSites()->getCurrentSite();
$llmModel = SeoFields::$plugin->defaultsService->getLlmForSite($site);

if (!$llmModel) {
throw new \yii\web\NotFoundHttpException();
}
$llmData = is_array($llmModel->llm) ? $llmModel->llm : Json::decodeIfJson($llmModel->llm) ?? [];

$markdown = SeoFields::$plugin->llmService->generateMarkdown($site, $llmData);

$headers = Craft::$app->response->headers;
$headers->add('Content-Type', 'text/markdown; charset=utf-8');
return $this->asRaw($markdown);
}
}
40 changes: 40 additions & 0 deletions src/migrations/m260213_181738_addLlmSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace studioespresso\seofields\migrations;

use craft\db\Migration;
use studioespresso\seofields\records\DefaultsRecord;

/**
* m260213_181738_addLlmSettings migration.
*/
class m260213_181738_addLlmSettings extends Migration
{
/**
* @inheritdoc
*/
public function safeUp(): bool
{
$this->addColumn(
DefaultsRecord::tableName(),
'enableLlm',
$this->boolean()->after('robots')
);
$this->addColumn(
DefaultsRecord::tableName(),
'llm',
$this->json()->after('enableLlm')
);

return true;
}

/**
* @inheritdoc
*/
public function safeDown(): bool
{
echo "m260213_181738_addLlmSettings cannot be reverted.\n";
return false;
}
}
Loading
Loading