diff --git a/app/Http/Controllers/DocsMarkdownController.php b/app/Http/Controllers/DocsMarkdownController.php
index 3f6c87d50..b1ce17f29 100644
--- a/app/Http/Controllers/DocsMarkdownController.php
+++ b/app/Http/Controllers/DocsMarkdownController.php
@@ -8,10 +8,12 @@
class DocsMarkdownController extends Controller
{
- public function __invoke(string $uri)
+ public function __invoke(string $uri = '')
{
+ $uri = $this->normalizeUri($uri);
+
$markdown = Cache::rememberForever("markdown.$uri", function () use ($uri) {
- $entry = Data::findByUri('/'.$uri);
+ $entry = Data::findByUri($uri);
throw_unless($entry, new NotFoundHttpException);
@@ -29,31 +31,59 @@ public function __invoke(string $uri)
]);
}
+ /**
+ * `/index.md` is the conventional Markdown twin of the home page — agents guess it, and
+ * appending `.md` to the root URL isn't possible. Everything else maps straight across.
+ */
+ private function normalizeUri(string $uri): string
+ {
+ $uri = trim($uri, '/');
+
+ return ($uri === '' || $uri === 'index') ? '/' : '/'.$uri;
+ }
+
+ /**
+ * Point internal links at their Markdown twins, so an agent following links from one
+ * `.md` page stays in Markdown instead of falling back into HTML.
+ */
private function appendMdExtensionToInternalLinks(string $markdown): string
{
return preg_replace_callback(
'/(?shouldAppendMdExtension($url)) {
- $url .= '.md';
- }
+ [, $text, $url] = $matches;
- return "[$text]($url)";
+ return "[$text]({$this->markdownUrl($url)})";
},
$markdown
);
}
- private function shouldAppendMdExtension(string $url): bool
+ private function markdownUrl(string $url): string
{
- if (preg_match('/^https?:\/\//', $url)) {
+ // Split the fragment/query off first: the extension belongs on the path, so
+ // "/tags/collection#parameters" has to become "/tags/collection.md#parameters".
+ $path = preg_split('/(?=[#?])/', $url, 2);
+ $suffix = $path[1] ?? '';
+ $path = $path[0];
+
+ return $this->shouldAppendMdExtension($path) ? $path.'.md'.$suffix : $url;
+ }
+
+ private function shouldAppendMdExtension(string $path): bool
+ {
+ // Empty path means the link was a bare fragment like "#overview".
+ if ($path === '') {
+ return false;
+ }
+
+ // Absolute URLs, protocol-relative URLs, and non-HTTP schemes (mailto:, tel:).
+ if (preg_match('/^([a-z][a-z0-9+.-]*:|\/\/)/i', $path)) {
return false;
}
- if (preg_match('/\.[a-z0-9]{2,4}$/i', $url)) {
+ // Already points at a file.
+ if (preg_match('/\.[a-z0-9]{2,4}$/i', $path)) {
return false;
}
diff --git a/app/Http/Controllers/LlmsTxtController.php b/app/Http/Controllers/LlmsTxtController.php
index f3d3ebb1e..0b25891ab 100644
--- a/app/Http/Controllers/LlmsTxtController.php
+++ b/app/Http/Controllers/LlmsTxtController.php
@@ -2,68 +2,168 @@
namespace App\Http\Controllers;
+use App\Support\MarkdownUrl;
use Illuminate\Support\Facades\Cache;
+use Statamic\Contracts\Entries\Entry as EntryContract;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
class LlmsTxtController extends Controller
{
+ /**
+ * Reference collections, appended after the main docs tree. These hold the bulk of the
+ * site — ~400 entries covering every tag, modifier, fieldtype and variable — and an agent
+ * that can't see them here has no way to discover that `{{ collection }}` exists.
+ */
+ private const REFERENCE_COLLECTIONS = [
+ 'tags',
+ 'modifiers',
+ 'fieldtypes',
+ 'variables',
+ 'widgets',
+ 'tips',
+ 'troubleshooting',
+ 'resource_apis',
+ ];
+
public function __invoke()
{
- $lines = Cache::rememberForever("llms.txt", function () {
- $tree = Collection::find('pages')->structure()->trees()->first()->tree();
- $lines = ['# Statamic Documentation', ''];
+ $lines = Cache::rememberForever('llms.txt', fn () => $this->build());
- foreach ($tree as $section) {
- $children = $section['children'] ?? [];
+ return response(implode("\n", $lines), 200, [
+ 'Content-Type' => 'text/plain; charset=UTF-8',
+ ]);
+ }
- if (! $children) {
- continue;
- }
+ private function build(): array
+ {
+ $docsVersion = config('docs.version');
+
+ $lines = [
+ '# Statamic Documentation',
+ '',
+ "> Statamic is a Laravel-powered CMS that stores content in flat files by default. This is the documentation for Statamic {$docsVersion}. Every page listed here is also available as Markdown — the `.md` URLs below return plain Markdown rather than HTML.",
+ '',
+ ];
+
+ foreach ($this->guide() as $line) {
+ $lines[] = $line;
+ }
+
+ foreach (self::REFERENCE_COLLECTIONS as $handle) {
+ foreach ($this->referenceSection($handle) as $line) {
+ $lines[] = $line;
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * The main docs, following the full depth of the page tree rather than just each
+ * section's immediate children.
+ */
+ private function guide(): array
+ {
+ $tree = Collection::find('pages')->structure()->trees()->first()->tree();
+ $lines = [];
+
+ foreach ($tree as $section) {
+ if (! $children = $section['children'] ?? []) {
+ continue;
+ }
- $sectionEntry = Entry::find($section['entry']);
- $lines[] = '## '.$sectionEntry->value('title');
+ if (! $sectionEntry = Entry::find($section['entry'])) {
+ continue;
+ }
+
+ $lines[] = '## '.$sectionEntry->value('title');
- $firstChild = Entry::find($children[0]['entry']);
- if ($firstChild && str_contains($firstChild->slug(), 'overview')) {
- if ($intro = $firstChild->value('intro')) {
- $lines[] = '> '.str_replace("\n", ' ', $intro);
- }
+ // Sections lead with an "overview" child whose intro describes the whole section.
+ $firstChild = Entry::find($children[0]['entry']);
+ if ($firstChild && str_contains($firstChild->slug(), 'overview')) {
+ if ($intro = $firstChild->value('intro')) {
+ $lines[] = '> '.$this->oneLine($intro);
}
+ }
- $lines[] = '';
+ $lines[] = '';
- foreach ($children as $child) {
- $entry = Entry::find($child['entry']);
- if (! $entry) {
- continue;
- }
+ foreach ($this->flatten($children) as $entry) {
+ $lines[] = $this->entryLine($entry);
+ }
- $url = $entry->url();
- if (! $url) {
- continue;
- }
+ $lines[] = '';
+ }
- $title = $entry->value('title');
- $isExternal = str_starts_with($url, 'http');
- $href = $isExternal ? $url : url($url).'.md';
- $line = '- ['.$title.']('.$href.')';
+ return $lines;
+ }
- if ($intro = $entry->value('intro')) {
- $line .= ': '.str_replace("\n", ' ', $intro);
- }
+ /**
+ * Walk a tree branch to any depth, returning entries in reading order.
+ */
+ private function flatten(array $branch): array
+ {
+ $entries = [];
- $lines[] = $line;
- }
+ foreach ($branch as $node) {
+ $entry = Entry::find($node['entry'] ?? null);
- $lines[] = '';
+ if ($entry && $entry->published()) {
+ $entries[] = $entry;
}
- return $lines;
- });
+ foreach ($this->flatten($node['children'] ?? []) as $descendant) {
+ $entries[] = $descendant;
+ }
+ }
- return response(implode("\n", $lines), 200, [
- 'Content-Type' => 'text/plain; charset=UTF-8',
- ]);
+ return $entries;
+ }
+
+ private function referenceSection(string $handle): array
+ {
+ if (! $collection = Collection::find($handle)) {
+ return [];
+ }
+
+ // "Reference" disambiguates these from the guide sections above, several of which
+ // share a name (the "Tags" guide explains tags; "Tags Reference" lists all 97).
+ $lines = ['## '.$collection->title().' Reference', ''];
+
+ $entries = $collection->queryEntries()
+ ->where('published', true)
+ ->orderBy('title', 'asc')
+ ->get();
+
+ foreach ($entries as $entry) {
+ $lines[] = $this->entryLine($entry);
+ }
+
+ $lines[] = '';
+
+ return $lines;
+ }
+
+ private function entryLine(EntryContract $entry): string
+ {
+ $url = $entry->url();
+
+ // Some tree nodes link off-site (ui.statamic.dev, YouTube). Those have no Markdown
+ // twin, so they're linked as-is.
+ $href = MarkdownUrl::for($url) ?? $url;
+
+ $line = '- ['.$entry->value('title').']('.$href.')';
+
+ if ($description = $entry->value('meta_description')) {
+ $line .= ': '.$this->oneLine($description);
+ }
+
+ return $line;
+ }
+
+ private function oneLine(string $text): string
+ {
+ return trim(preg_replace('/\s+/', ' ', $text) ?? $text);
}
}
diff --git a/app/Http/Controllers/RobotsTxtController.php b/app/Http/Controllers/RobotsTxtController.php
new file mode 100644
index 000000000..1f6619a18
--- /dev/null
+++ b/app/Http/Controllers/RobotsTxtController.php
@@ -0,0 +1,69 @@
+contentSignals(),
+ '',
+ ];
+
+ foreach ($this->aiCrawlers as $crawler) {
+ $lines[] = "User-agent: {$crawler}";
+ }
+
+ $lines[] = 'Allow: /';
+ $lines[] = $this->contentSignals();
+ $lines[] = '';
+ $lines[] = 'Sitemap: '.url('/sitemap.xml');
+ $lines[] = '';
+
+ return response(implode("\n", $lines), 200, [
+ 'Content-Type' => 'text/plain; charset=UTF-8',
+ ]);
+ }
+
+ /**
+ * Content Signals (https://contentsignals.org) declare how this content may be used.
+ * The docs are open source, and models knowing Statamic is good for Statamic, so we
+ * permit all three uses.
+ */
+ private function contentSignals(): string
+ {
+ return 'Content-Signal: search=yes, ai-input=yes, ai-train=yes';
+ }
+}
diff --git a/app/Modifiers/MarkdownUrl.php b/app/Modifiers/MarkdownUrl.php
new file mode 100644
index 000000000..5ab4a7bca
--- /dev/null
+++ b/app/Modifiers/MarkdownUrl.php
@@ -0,0 +1,17 @@
+registerComputedValues();
+
StorybookSearchProvider::register();
}
+
+ /**
+ * A value every collection needs but no blueprint defines. Registering it as a computed
+ * value means one implementation serves Antlers templates ({{ meta_description }}) and
+ * PHP alike ($entry->value('meta_description')).
+ */
+ private function registerComputedValues(): void
+ {
+ $collections = [
+ 'pages', 'tags', 'modifiers', 'fieldtypes', 'variables',
+ 'widgets', 'tips', 'troubleshooting', 'resource_apis',
+ ];
+
+ Collection::computed($collections, 'meta_description', fn ($entry) => Description::for($entry));
+ }
}
diff --git a/app/Support/Description.php b/app/Support/Description.php
new file mode 100644
index 000000000..8d8a59796
--- /dev/null
+++ b/app/Support/Description.php
@@ -0,0 +1,118 @@
+value($field)) {
+ return self::tidy(self::stripInlineMarkdown($value));
+ }
+ }
+
+ return self::tidy(self::firstParagraph((string) $entry->value('content')));
+ }
+
+ /**
+ * Pull the first prose paragraph out of a raw Markdown body.
+ *
+ * Deliberately works on the raw Markdown rather than rendered HTML: these pages are
+ * code-heavy, and rendering first would mean fighting Torchlight's syntax highlighting
+ * markup to get back to plain text.
+ */
+ public static function firstParagraph(string $markdown): string
+ {
+ if (trim($markdown) === '') {
+ return '';
+ }
+
+ $markdown = self::stripBlocks($markdown);
+
+ foreach (preg_split('/\n\s*\n/', $markdown) as $paragraph) {
+ $paragraph = trim($paragraph);
+
+ if ($paragraph === '' || self::isNotProse($paragraph)) {
+ continue;
+ }
+
+ return self::stripInlineMarkdown($paragraph);
+ }
+
+ return '';
+ }
+
+ /**
+ * Remove block-level constructs that never make sense in a description: fenced code,
+ * HTML, and the custom `::tabs` / `:::tip` syntax handled by our CommonMark extensions
+ * in app/Markdown.
+ */
+ private static function stripBlocks(string $markdown): string
+ {
+ $patterns = [
+ '/^```.*?^```/ms', // fenced code blocks
+ '/^~~~.*?^~~~/ms', // alternate fence
+ '/^::tabs.*?^::\/tabs/ms', // tabbed code blocks (fully closed)
+ '/^::tab[^\n]*$/m', // stray tab markers
+ '/^::\/?tabs?[^\n]*$/m',
+ '/^:{3,}[^\n]*$/m', // hint block delimiters (:::tip, :::warning, :::)
+ // Headings are dropped line-by-line rather than as whole paragraphs: plenty of
+ // pages open with a heading on the line directly above their first prose, with no
+ // blank line between them.
+ '/^#{1,6}[ \t][^\n]*$/m',
+ '/^<[^\n]*>$/m', // standalone HTML lines
+ '/^\{\{.*?\}\}$/ms', // Antlers left in content
+ ];
+
+ return preg_replace($patterns, '', $markdown) ?? $markdown;
+ }
+
+ /**
+ * Lines that are structural rather than prose — headings, list items, tables,
+ * blockquotes, images and indented code.
+ */
+ private static function isNotProse(string $paragraph): bool
+ {
+ return (bool) preg_match('/^(#|>|\||[-*+]\s|\d+\.\s|!\[| |\t)/', $paragraph);
+ }
+
+ private static function stripInlineMarkdown(string $text): string
+ {
+ $replacements = [
+ '/!\[[^\]]*\]\([^)]*\)/' => '', // images
+ '/\[([^\]]+)\]\([^)]*\)/' => '$1', // links → their text
+ '/`([^`]+)`/' => '$1', // inline code
+ '/\*\*([^*]+)\*\*/' => '$1', // bold
+ '/(? '$1', // italics
+ '/<[^>]+>/' => '', // inline HTML
+ ];
+
+ return preg_replace(array_keys($replacements), array_values($replacements), $text) ?? $text;
+ }
+
+ private static function tidy(string $text): string
+ {
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
+
+ // Many paragraphs are lead-ins to a code block ("...use the following Facade:").
+ // The trailing colon dangles once the code is gone.
+ $text = rtrim($text, ':');
+
+ return Str::limit($text, self::MAX_LENGTH, '…', preserveWords: true);
+ }
+}
diff --git a/app/Support/MarkdownUrl.php b/app/Support/MarkdownUrl.php
new file mode 100644
index 000000000..f3e4b8be6
--- /dev/null
+++ b/app/Support/MarkdownUrl.php
@@ -0,0 +1,28 @@
+
-
-
-
-
+
+{{ if noindex }}
+
+{{ else }}
+ {{# max-snippet:-1 lets answer engines quote a full passage rather than a clipped one. #}}
+
+{{ /if }}
+{{ if id }}
+ {{# Every page has a Markdown twin. It's a fraction of the tokens of the HTML. #}}
+
+{{ /if }}
+
+{{ if id }}
+
+{{ else }}
+
+{{ /if }}
+
+
+
-
+
-
-{{ partial:favicons }}
\ No newline at end of file
+
+{{ partial:favicons }}
diff --git a/resources/views/sitemap.antlers.html b/resources/views/sitemap.antlers.html
index 9a69aef0f..717e64992 100644
--- a/resources/views/sitemap.antlers.html
+++ b/resources/views/sitemap.antlers.html
@@ -1,11 +1,5 @@
-{{ get_content from="6aa5449b-5d90-47de-97e7-82ba5f665250" }}
-
- {{ permalink remove_right="/documentation" }}
- {{ last_modified format="Y-m-d" }}
-
-{{ /get_content }}
{{ collection from="*" }}
{{ permalink }}
diff --git a/routes/web.php b/routes/web.php
index 63cfa9098..47ccd257d 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,12 +2,14 @@
use App\Http\Controllers\LlmsTxtController;
use App\Http\Controllers\DocsMarkdownController;
+use App\Http\Controllers\RobotsTxtController;
use Statamic\Facades\Entry;
+Route::get('robots.txt', RobotsTxtController::class);
Route::get('llms.txt', LlmsTxtController::class);
Route::get('{any}.md', DocsMarkdownController::class)->where('any', '.*');
-Route::statamic('search-results', 'search', ['hide_sidebar' => true]);
+Route::statamic('search-results', 'search', ['hide_sidebar' => true, 'noindex' => true]);
Route::statamic('sitemap.xml', 'sitemap', ['content_type' => 'xml', 'layout' => 'sitemap']);
Route::get('versions.json', fn () => config('docs.versions'));