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'));