Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
9c85c08
Add tab for llms.txt to Crawling & AI section
joshuablum Aug 31, 2026
d3ae846
Add initial feature implementation
joshuablum Sep 1, 2026
340068d
Add CLI command to generate llms.txt
joshuablum Sep 1, 2026
087cbb1
Add llms.txt to GraphQL
joshuablum Sep 1, 2026
06fc5e5
Add llms.txt to meta partial
joshuablum Sep 1, 2026
9dbe3cb
Tests
joshuablum Sep 1, 2026
5a6d0a5
Fix test on Windows
joshuablum Sep 1, 2026
ecb991c
Checks there's only one h1 and remove clanker stupidity
joshuablum Sep 1, 2026
c0ad445
Hide settings/config when feature is disabled
joshuablum Sep 1, 2026
4c3dc80
Hide preview on custom mode
joshuablum Sep 1, 2026
81d35b7
Update Release Workflow (#653)
duncanmcclean Sep 3, 2026
a9a6aea
Add configurable cap on tracked 404 errors (#654)
duncanmcclean Sep 3, 2026
3a3cf4d
Fix errors with no `last_hit_at` never being purged (#655)
duncanmcclean Sep 3, 2026
d38bac3
Add delete action for tracked 404 errors (#656)
duncanmcclean Sep 3, 2026
49364e1
Add delete action for redirects (#657)
duncanmcclean Sep 3, 2026
493f00b
7.14.0 (#658)
duncanmcclean Sep 3, 2026
93da19f
Add the ability to include entire collections or specific entries in …
joshuablum Sep 4, 2026
d179642
Tests
joshuablum Sep 4, 2026
f6accfb
Properly check for authorization
joshuablum Sep 4, 2026
b9d11a6
Site-aware llms.txt routes
joshuablum Sep 4, 2026
3915613
Handle changes of site URLs/paths
joshuablum Sep 4, 2026
de8eba1
Tweak preview logic and don't render previews in custom source mode
joshuablum Sep 4, 2026
11f67df
Sanitize and escape llms_txt in meta partial
joshuablum Sep 4, 2026
0c8b18c
Fall back when social image Glide fails (#660)
edalzell Sep 7, 2026
aaa2c0b
7.14.1 (#662)
duncanmcclean Sep 7, 2026
4f2569b
Fix dark mode colours in the report Page Details modal (#666)
duncanmcclean Sep 17, 2026
4c6ffa4
7.14.2 (#667)
duncanmcclean Sep 17, 2026
83f2375
Fix duplicate and contradictory messages in ideal length rules (#668)
helloDanuk Sep 24, 2026
4bb66a3
Skip redirects that resolve to the URL being visited (#673)
duncanmcclean Sep 24, 2026
8d5fe72
7.14.3 (#674)
duncanmcclean Sep 24, 2026
9f9d58e
Fix tests
joshuablum Sep 24, 2026
5929842
Tweak validation and fix console errors
joshuablum Sep 24, 2026
25d0431
Document llms.txt support
joshuablum Sep 24, 2026
e573aa5
Merge remote-tracking branch 'origin/7.x' into llms-txt-support
joshuablum Sep 24, 2026
f18a336
Support immutable dates when generating llms.txt
joshuablum Sep 24, 2026
9e7e0d2
Encode non-ASCII link URLs and skip invalid entry links
joshuablum Sep 24, 2026
3f66606
Warn about llms.txt size limit and serve the last good version
joshuablum Sep 24, 2026
8e24961
Wait for an llms.txt generation in progress instead of failing
joshuablum Sep 24, 2026
9199ab9
Release changed file at previous site path
joshuablum Sep 24, 2026
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 .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/resources/dist/**/* linguist-generated=true
105 changes: 82 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,51 +1,110 @@
name: Create Release

on: # zizmor: ignore[concurrency-limits]
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release version, without the v prefix (e.g. 1.2.0)'
required: true
type: string

permissions: {}

jobs:
build: # zizmor: ignore[anonymous-definition]
release:
name: Create Release
runs-on: ubuntu-latest
permissions:
contents: write # create GitHub release and upload assets
environment: Releases
permissions: {} # all writes go through the App token; GITHUB_TOKEN needs no scopes
steps:
- name: Checkout code
- name: Resolve and validate version
env:
INPUT_VERSION: ${{ inputs.version }}
BRANCH: ${{ github.ref_name }}
run: |
version="${INPUT_VERSION#v}"

if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::Version must look like 1.2.0 (got: $INPUT_VERSION)"
exit 1
fi

if [[ "$BRANCH" =~ ^[0-9]+\.x$ ]] && [ "${version%%.*}" != "${BRANCH%%.*}" ]; then
echo "::error::Version $version does not belong on the $BRANCH branch"
exit 1
fi

echo "TAG=v$version" >> "$GITHUB_ENV"

- name: Get release bot token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write

- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true # zizmor: ignore[artipacked] App token is needed to push the tag

- name: Install Composer Dependencies
- name: Assert tag does not already exist
run: |
existing="$(git ls-remote --tags origin "$TAG")"
[ -z "$existing" ] || { echo "::error::Tag $TAG already exists on remote. Aborting."; exit 1; }

- name: Use Node.js 20.19.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20.19.0
package-manager-cache: false

- name: Install Composer dependencies
run: composer install

- name: Install NPM Dependencies
- name: Install NPM dependencies
run: npm ci

- name: Compile assets
- name: Build release assets
run: npm run build

- name: Create zip
run: cd resources && tar -czvf dist.tar.gz dist
- name: Create the build commit
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
USER_ID="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
git config user.name "${APP_SLUG}[bot]"
git config user.email "${USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
git add --force resources/dist
git commit -m "Build assets for $TAG"

- name: Tag and push the build commit
run: |
git tag "$TAG"
git push origin "$TAG"

- name: Get Changelog
id: changelog
uses: statamic/changelog-action@5d112d0d790cdeeb5adca3e584e37edc474ab51b # v1
uses: statamic/changelog-action@5d112d0d790cdeeb5adca3e584e37edc474ab51b # v1.0.2
with:
version: ${{ github.ref }}
version: ${{ env.TAG }}

- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.changelog.outputs.version }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RELEASE_NOTES: ${{ steps.changelog.outputs.text }}
IS_DEFAULT_BRANCH: ${{ github.ref_name == github.event.repository.default_branch }}
run: |
PRERELEASE=""
if [[ "$RELEASE_VERSION" == *-beta* ]]; then PRERELEASE="--prerelease"; fi
gh release create "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \
latest="$IS_DEFAULT_BRANCH"
prerelease=false
case "$TAG" in
*-*) prerelease=true; latest=false ;;
esac
gh release create "$TAG" \
--title "$TAG" \
--notes "$RELEASE_NOTES" \
$PRERELEASE \
./resources/dist.tar.gz
--latest="$latest" \
--prerelease="$prerelease"
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Release Notes

## 7.14.3 (2026-09-24)

### What's fixed
- Fix duplicate and contradictory messages in ideal length rules [#668](https://github.com/statamic/seo-pro/issues/668) by @helloDanuk
- Skip redirects that resolve to the URL being visited [#673](https://github.com/statamic/seo-pro/issues/673) by @duncanmcclean



## 7.14.2 (2026-09-17)

### What's fixed
- Fix dark mode colours in the report Page Details modal [#666](https://github.com/statamic/seo-pro/issues/666) by @duncanmcclean



## 7.14.1 (2026-09-07)

### What's fixed
- Fall back to the raw asset URL when Glide can't generate a social image [#660](https://github.com/statamic/seo-pro/issues/660) by @edalzell



## 7.14.0 (2026-09-03)

### What's new
- Add configurable cap on tracked 404 errors [#654](https://github.com/statamic/seo-pro/issues/654) by @duncanmcclean
- Add delete action for tracked 404 errors [#656](https://github.com/statamic/seo-pro/issues/656) by @duncanmcclean
- Add delete action for redirects [#657](https://github.com/statamic/seo-pro/issues/657) by @duncanmcclean

### What's fixed
- Fix errors with no `last_hit_at` never being purged [#655](https://github.com/statamic/seo-pro/issues/655) by @duncanmcclean



## 7.13.3 (2026-08-25)

### What's fixed
Expand Down
73 changes: 73 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,67 @@ php please seo-pro:generate:robots-txt

Robots settings and generation metadata use Statamic's addon settings repository. By default, they are stored with the other SEO Pro settings in `resources/addons/seo-pro.yaml`.

## llms.txt

SEO Pro can publish an [`llms.txt`](https://llmstxt.org) document for each Statamic site. The feature is opt-in and returns a 404 until it is enabled in `Tools > SEO Pro > Crawling & AI > llms.txt`.

Managed mode builds the document from a required H1 title, an optional summary, optional additional Markdown context, selected Statamic content, and optional sections of curated links. Selected collections include all their published, routable entries for the current site. Entries can also be selected individually. SEO Pro groups these links by collection and removes duplicates when an entry is selected both ways. Custom mode accepts the complete Markdown source. Antlers is parsed in both modes, so values such as `{{ config:app:name }}` can be used. When enabled, the resolved output must begin with a non-empty H1 and contain exactly one Markdown H1 in total.

### Routes and Caching

The dynamic route is available at `/llms.txt`, or beneath the site's base path for directory-based multi-site installations, such as `/fr/llms.txt`. Rendered output is cached using a fingerprint of the settings, site, and Antlers configuration context, so the document is not rebuilt on every request. SEO Pro invalidates that cache when the settings or selected Statamic content change.

Statamic Half Measure static caching stores and restores the complete plain-text response, including its content type. Full Measure normally writes every response as an `.html` file, which common rewrite rules serve as HTML. SEO Pro therefore keeps this route out of Full Measure's HTML cache and uses its own rendered-content cache. Generate a physical file when the web server should serve it directly.

The enabled document is also exposed as `llms_txt` in SEO Pro's cascade and GraphQL type, and the meta tag includes a `rel="describedby"` link. SEO Pro adds enabled URLs to Statamic's regular static-warm command. It does not register URLs with `statamic/ssg`; add the appropriate route to your SSG configuration when required by your deployment.

### Physical Files and Deployments

Select **Generate** to create a physical file, normally `public/llms.txt`. For a directory-based site, the path follows the site's base path, such as `public/fr/llms.txt`. Once created, SEO Pro keeps that file synchronized when its settings are saved and removes it when the feature is disabled.

Ownership is verified using the checksum stored in SEO Pro's generation metadata. SEO Pro will never overwrite or remove an existing file that it does not manage. Manually changing a generated file relinquishes ownership, and the Control Panel displays a warning instead of modifying it.

Git-based and deployment workflows can maintain the same site-scoped settings directly in `resources/addons/seo-pro.yaml` when a project does not use the Control Panel:

```yaml
llms:
sites:
default:
policy:
enabled: true
mode: managed
title: '{{ config:app:name }}'
summary: A concise description of the site.
details: Optional additional Markdown context.
collections:
- pages
entries:
- 62136fa2-9e5c-4c38-a894-a2753f02f5ff
sections:
- title: Documentation
links:
- title: Getting started
url: https://example.com/docs/getting-started.md
description: The main setup guide.
custom_source: ''
```

SEO Pro adds the `generated` ownership metadata after creating the physical file; it should not be authored manually.

Generate all enabled sites from the command line:

```shell
php please seo-pro:generate:llms-txt
```

Use one or more `--site` options to select site handles:

```shell
php please seo-pro:generate:llms-txt --site=default --site=french
```

If multiple domains resolve to the same physical public path, generate one site per deployment using `--site`. Generated files are added to Statamic's Git-tracked paths, and a successful physical change dispatches `Statamic\SeoPro\Events\LlmsTxtGenerated`.

## Redirects

SEO Pro includes a redirect manager that lets you define URL redirects, automatically create redirects when slugs change, and track 404 errors.
Expand Down Expand Up @@ -393,6 +454,18 @@ You may customize the purge threshold in the config:
],
```

On a public site, bots probing for unique URLs can create an unbounded number of errors between purges. To bound the total, set `max_errors` to a cap. When the purge runs and the number of errors exceeds the cap, the excess is purged: errors that have never been hit go first, then those with the fewest hits, then those hit least recently. Frequently-hit 404s, the best redirect candidates, survive longest. The default of `0` disables the cap.

```php
// config/statamic/seo-pro.php

'redirects' => [
'errors' => [
'max_errors' => 1000,
],
],
```

You may also run the command manually:

```
Expand Down
14 changes: 1 addition & 13 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@
}
},
"extra": {
"download-dist": [
{
"url": "https://github.com/statamic/seo-pro/releases/download/{$version}/dist.tar.gz",
"path": "resources/dist"
}
],
"statamic": {
"name": "SEO Pro",
"description": "An all-in-one site reporting, metadata mastering, OG managing, Twitter card making, sitemap generating addon."
Expand All @@ -28,8 +22,7 @@
}
},
"require": {
"statamic/cms": "^6.25",
"pixelfear/composer-dist-plugin": "^0.1.6",
"statamic/cms": "^6.34",
"spatie/simple-excel": "^3.9"
},
"require-dev": {
Expand All @@ -38,11 +31,6 @@
"laravel/pint": "1.29.1",
"spatie/laravel-ray": "^1.43.6"
},
"config": {
"allow-plugins": {
"pixelfear/composer-dist-plugin": true
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
1 change: 1 addition & 0 deletions config/seo-pro.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
'driver' => 'file',
'directory' => storage_path('statamic/seopro/errors'),
'purge_after_days' => 30,
'max_errors' => 0,
],
],

Expand Down
4 changes: 2 additions & 2 deletions lang/da/fieldsets/content.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
'enabled_instruct' => 'Hvis du deaktiverer dette element, vil det blive udelukket fra rapporter og sitemap, og forhindre at noget bliver vist gennem skabelon-tagget.',

'title' => 'Meta Titel',
'title_instruct' => 'Hver URL på din hjemmeside bør have en unik Meta Titel, ideelt set mindre end 60 tegn lang.',
'title_instruct' => 'Hver URL på din hjemmeside bør have en unik Meta Titel, ideelt set :min–:max tegn lang.',

'description' => 'Meta Beskrivelse',
'description_instruct' => 'Hver URL på din hjemmeside bør have en unik Meta Beskrivelse, ideelt set mindre end 160 tegn lang.',
'description_instruct' => 'Hver URL på din hjemmeside bør have en unik Meta Beskrivelse, ideelt set :min–:max tegn lang.',

'site_name' => 'Webstedsnavn',
'site_name_instruct' => 'Deaktiver webstedsnavnet for denne side, hvis det ønskes.',
Expand Down
4 changes: 2 additions & 2 deletions lang/de/fieldsets/content.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
'enabled_instruct' => 'Wenn du diesen Eintrag deaktivierst, wird er aus Berichten und der Sitemap ausgeschlossen – und es wird nichts über das Template-Tag ausgegeben.',

'title' => 'Meta-Titel',
'title_instruct' => 'Jede URL auf deiner Website sollte einen eindeutigen Meta-Titel haben – idealerweise mit weniger als 60 Zeichen.',
'title_instruct' => 'Jede URL auf deiner Website sollte einen eindeutigen Meta-Titel haben – idealerweise mit :min–:max Zeichen.',

'description' => 'Meta-Beschreibung',
'description_instruct' => 'Jede URL auf deiner Website sollte eine eindeutige Meta-Beschreibung haben – idealerweise mit weniger als 160 Zeichen.',
'description_instruct' => 'Jede URL auf deiner Website sollte eine eindeutige Meta-Beschreibung haben – idealerweise mit :min–:max Zeichen.',

'site_name' => 'Website-Name',
'site_name_instruct' => 'Deaktiviere optional den Website-Namen auf dieser Seite.',
Expand Down
20 changes: 10 additions & 10 deletions lang/de/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,24 +127,24 @@
'three_segment_urls_site_warning' => ':count Seite mit mehr als 3 Segmenten in der URL.|:count Seiten mit mehr als 3 Segmenten in ihren URLs.',
'title_length_actionable_pill' => 'Titel',
'title_length_site' => 'Titel-Tags sollten eine Länge im empfohlenen Bereich haben.',
'title_length_page' => 'Dein Titel-Tag ist zu lang.',
'title_length_page' => 'Titel-Tag liegt im empfohlenen Bereich.',
'title_length_site_warning' => ':count Seite mit Titel-Tag außerhalb des empfohlenen Bereichs.|:count Seiten mit Titel-Tags außerhalb des empfohlenen Bereichs.',
'title_length_site_failing' => ':count Seite mit zu langem oder fehlendem Titel-Tag.|:count Seiten mit zu langen oder fehlenden Titel-Tags.',
'title_length_page_warning' => 'Titel-Tag ist :length Zeichen lang (außerhalb des empfohlenen Bereichs).',
'title_length_page_warning' => 'Titel-Tag ist :length Zeichen lang. Die empfohlene Länge ist :min–:max.',
'title_length_page_failing_missing' => 'Titel-Tag fehlt.',
'title_length_page_failing_too_short' => 'Titel-Tag ist :length Zeichen lang. Die empfohlene Länge ist ≥ :min.',
'title_length_page_failing_too_long' => 'Titel-Tag ist :length Zeichen lang. Die empfohlene Länge ist < :max.',
'title_length_page_passing' => 'Titel-Tag ist :length Zeichen lang (empfohlene Länge).',
'title_length_page_failing_too_short' => 'Titel-Tag ist zu kurz.',
'title_length_page_failing_too_long' => 'Titel-Tag ist zu lang.',
'title_length_page_passing' => 'Titel-Tag ist :length Zeichen lang.',
'meta_description_length_actionable_pill' => 'Beschreibung',
'meta_description_length_site' => 'Meta-Beschreibungen sollten eine Länge im empfohlenen Bereich haben.',
'meta_description_length_page' => 'Deine Meta-Beschreibung ist zu lang.',
'meta_description_length_page' => 'Meta-Beschreibung liegt im empfohlenen Bereich.',
'meta_description_length_site_warning' => ':count Seite mit Meta-Beschreibung außerhalb des empfohlenen Bereichs.|:count Seiten mit Meta-Beschreibungen außerhalb des empfohlenen Bereichs.',
'meta_description_length_site_failing' => ':count Seite mit zu langer oder fehlender Meta-Beschreibung.|:count Seiten mit zu langen oder fehlenden Meta-Beschreibungen.',
'meta_description_length_page_warning' => 'Meta-Beschreibung ist :length Zeichen lang (außerhalb des empfohlenen Bereichs).',
'meta_description_length_page_warning' => 'Meta-Beschreibung ist :length Zeichen lang. Die empfohlene Länge ist :min–:max.',
'meta_description_length_page_failing_missing' => 'Meta-Beschreibung fehlt.',
'meta_description_length_page_failing_too_short' => 'Meta-Beschreibung ist :length Zeichen lang. Die empfohlene Länge ist ≥ :min.',
'meta_description_length_page_failing_too_long' => 'Meta-Beschreibung ist :length Zeichen lang. Die empfohlene Länge ist < :max.',
'meta_description_length_page_passing' => 'Meta-Beschreibung ist :length Zeichen lang (empfohlene Länge).',
'meta_description_length_page_failing_too_short' => 'Meta-Beschreibung ist zu kurz.',
'meta_description_length_page_failing_too_long' => 'Meta-Beschreibung ist zu lang.',
'meta_description_length_page_passing' => 'Meta-Beschreibung ist :length Zeichen lang.',
],

];
4 changes: 2 additions & 2 deletions lang/de_CH/fieldsets/content.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
'enabled_instruct' => 'Wenn du diesen Eintrag deaktivierst, wird er aus Berichten und der Sitemap ausgeschlossen – und es wird nichts über das Template-Tag ausgegeben.',

'title' => 'Meta-Titel',
'title_instruct' => 'Jede URL auf deiner Website sollte einen eindeutigen Meta-Titel haben – idealerweise mit weniger als 60 Zeichen.',
'title_instruct' => 'Jede URL auf deiner Website sollte einen eindeutigen Meta-Titel haben – idealerweise mit :min–:max Zeichen.',

'description' => 'Meta-Beschreibung',
'description_instruct' => 'Jede URL auf deiner Website sollte eine eindeutige Meta-Beschreibung haben – idealerweise mit weniger als 160 Zeichen.',
'description_instruct' => 'Jede URL auf deiner Website sollte eine eindeutige Meta-Beschreibung haben – idealerweise mit :min–:max Zeichen.',

'site_name' => 'Website-Name',
'site_name_instruct' => 'Deaktiviere optional den Website-Namen auf dieser Seite.',
Expand Down
Loading
Loading