diff --git a/app/Http/Controllers/DocsMarkdownController.php b/app/Http/Controllers/DocsMarkdownController.php
index 3f6c87d50..376e2bb1c 100644
--- a/app/Http/Controllers/DocsMarkdownController.php
+++ b/app/Http/Controllers/DocsMarkdownController.php
@@ -2,19 +2,27 @@
namespace App\Http\Controllers;
+use App\Support\MarkdownUrl;
+use Illuminate\Http\Request;
+use Illuminate\Routing\RedirectController;
+use Illuminate\Routing\Router;
+use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\Cache;
use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Facades\Data;
class DocsMarkdownController extends Controller
{
- public function __invoke(string $uri)
+ public function __invoke(string $uri = '')
{
- $markdown = Cache::rememberForever("markdown.$uri", function () use ($uri) {
- $entry = Data::findByUri('/'.$uri);
+ $uri = $this->normalizeUri($uri);
+ $entry = Data::findByUri($uri);
- throw_unless($entry, new NotFoundHttpException);
+ if (! $entry) {
+ return $this->redirectLegacyUri($uri);
+ }
+ $markdown = Cache::rememberForever("markdown.$uri", function () use ($entry) {
return collect([
'# '.$entry->value('title'),
$entry->value('intro'),
@@ -24,36 +32,98 @@ public function __invoke(string $uri)
$markdown = $this->appendMdExtensionToInternalLinks($markdown);
- return response($markdown, 200, [
+ $response = response($markdown, 200, [
'Content-Type' => 'text/markdown; charset=UTF-8',
]);
+
+ $response->headers->set('Link', implode(', ', [
+ sprintf('<%s>; rel="canonical"; type="text/html"', url($entry->url())),
+ sprintf('<%s>; rel="describedby"; type="text/plain"', url('/llms.txt')),
+ ]), false);
+
+ return $response;
+ }
+
+ /**
+ * `/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;
}
+ /**
+ * Mirror the redirects used by the HTML docs, but keep internal destinations in
+ * Markdown. This makes legacy links such as `/users.md` redirect to the canonical
+ * `/control-panel/users.md` instead of returning a 404.
+ */
+ private function redirectLegacyUri(string $uri)
+ {
+ $request = Request::create($uri, 'GET');
+ $route = app(Router::class)->getRoutes()->match($request);
+
+ if (ltrim($route->getActionName(), '\\') !== RedirectController::class) {
+ throw new NotFoundHttpException;
+ }
+
+ $request->setRouteResolver(fn () => $route);
+
+ $redirect = app(RedirectController::class)(
+ $request,
+ app(UrlGenerator::class),
+ );
+
+ $destination = $redirect->getTargetUrl();
+ $destination = MarkdownUrl::for($destination) ?? $destination;
+
+ return redirect()->away($destination, $redirect->getStatusCode());
+ }
+
+ /**
+ * 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
+ {
+ // 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
{
- if (preg_match('/^https?:\/\//', $url)) {
+ // 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/Http/Middleware/ServeMarkdown.php b/app/Http/Middleware/ServeMarkdown.php
new file mode 100644
index 000000000..c73f39d05
--- /dev/null
+++ b/app/Http/Middleware/ServeMarkdown.php
@@ -0,0 +1,101 @@
+isMethod('GET') && ! $request->isMethod('HEAD')) {
+ return $next($request);
+ }
+
+ $uri = '/'.trim($request->path(), '/');
+
+ if ($uri === '/') {
+ $uri = '';
+ }
+
+ $entry = Data::findByUri($uri === '' ? '/' : $uri);
+
+ if (! $entry) {
+ return $this->handlePotentialDocsRedirect($request, $next($request));
+ }
+
+ $prefersMarkdown = $this->prefersMarkdown($request);
+
+ $response = $prefersMarkdown
+ ? ($this->markdown)($uri)
+ : $next($request);
+
+ if (! $prefersMarkdown) {
+ $response->headers->set('Link', implode(', ', [
+ sprintf('<%s>; rel="alternate"; type="text/markdown"', MarkdownUrl::for($entry->url())),
+ sprintf('<%s>; rel="describedby"; type="text/plain"', url('/llms.txt')),
+ ]), false);
+ }
+
+ $response->setVary('Accept', false);
+
+ if ($request->isMethod('HEAD')) {
+ $response->setContent('');
+ }
+
+ return $response;
+ }
+
+ /**
+ * When a legacy HTML URL redirects to a documentation entry, point clients that asked
+ * for Markdown directly at the destination's Markdown twin. This does not rely on a
+ * particular HTTP client retaining its Accept header while following the redirect.
+ */
+ private function handlePotentialDocsRedirect(Request $request, Response $response): Response
+ {
+ if (! $response instanceof RedirectResponse) {
+ return $response;
+ }
+
+ $destination = $response->getTargetUrl();
+ $path = parse_url($destination, PHP_URL_PATH);
+
+ if (! is_string($path) || ! Data::findByUri($path)) {
+ return $response;
+ }
+
+ $response->setVary('Accept', false);
+
+ if ($this->prefersMarkdown($request)) {
+ $response->setTargetUrl(MarkdownUrl::for($destination) ?? $destination);
+ }
+
+ return $response;
+ }
+
+ private function prefersMarkdown(Request $request): bool
+ {
+ $accept = strtolower((string) $request->header('Accept'));
+ $header = AcceptHeader::fromString($accept);
+
+ // A wildcard Accept header should continue to receive the normal HTML response.
+ // Negotiate Markdown only when the client explicitly asks for it and prefers it.
+ if (! $header->has('text/markdown')) {
+ return false;
+ }
+
+ $markdown = $header->get('text/markdown');
+
+ return $markdown->getQuality() > 0
+ && $request->prefers(['text/markdown', 'text/html']) === 'text/markdown';
+ }
+}
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..881fdaff8
--- /dev/null
+++ b/app/Support/MarkdownUrl.php
@@ -0,0 +1,39 @@
+withMiddleware(function (Middleware $middleware): void {
- //
+ $middleware->web(prepend: [ServeMarkdown::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
diff --git a/public/robots.txt b/public/robots.txt
deleted file mode 100644
index eb0536286..000000000
--- a/public/robots.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-User-agent: *
-Disallow:
diff --git a/resources/views/partials/meta.antlers.html b/resources/views/partials/meta.antlers.html
index f6c54e3a5..d3f4d21a0 100644
--- a/resources/views/partials/meta.antlers.html
+++ b/resources/views/partials/meta.antlers.html
@@ -1,14 +1,29 @@
-
-
-
-
-
+
+{{ 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/redirects.php b/routes/redirects.php
index da94e3fb8..eb0fab0aa 100644
--- a/routes/redirects.php
+++ b/routes/redirects.php
@@ -77,6 +77,7 @@
Route::permanentRedirect('tips/storing-content-in-a-database', '/knowledge-base/tips/storing-content-in-a-database');
Route::permanentRedirect('tips/storing-users-in-a-database', '/knowledge-base/tips/storing-users-in-a-database');
Route::permanentRedirect('tips/timezones', '/knowledge-base/tips/timezones');
+Route::permanentRedirect('tips/using-statamic-with-laravel-nightwatch', '/knowledge-base/tips/using-statamic-with-laravel-nightwatch');
Route::permanentRedirect('tips/excluding-the-control-panel-from-maintenance-mode', '/knowledge-base/tips/excluding-the-control-panel-from-maintenance-mode');
Route::permanentRedirect('upgrade-guide/3-0-to-3-1', '/getting-started/upgrade-guide/3-0-to-3-1');
Route::permanentRedirect('upgrade-guide/3-1-to-3-2', '/getting-started/upgrade-guide/3-1-to-3-2');
@@ -119,6 +120,7 @@
Route::permanentRedirect('installing/docker', '/getting-started/installing/docker');
Route::permanentRedirect('email', '/advanced-topics/email');
Route::permanentRedirect('fields', '/content-modeling/fields');
+Route::permanentRedirect('fieldset', '/content-modeling/fieldsets');
Route::permanentRedirect('fieldsets', '/content-modeling/fieldsets');
Route::permanentRedirect('fieldtypes', '/fieldtypes/overview');
Route::permanentRedirect('forms', '/frontend/forms');
@@ -154,6 +156,7 @@
Route::permanentRedirect('quick-start-guide', '/getting-started/quick-start-guide');
Route::permanentRedirect('recent-updates', '/');
Route::permanentRedirect('relationships', '/content-modeling/relationships');
+Route::permanentRedirect('replicator', '/fieldtypes/replicator');
Route::permanentRedirect('release-schedule-support-policy', '/knowledge-base/release-schedule-support-policy');
Route::permanentRedirect('requirements', '/getting-started/requirements');
Route::permanentRedirect('rest-api', '/frontend/rest-api');
@@ -173,6 +176,7 @@
Route::permanentRedirect('updating', '/getting-started/updating');
Route::permanentRedirect('upgrade-guide', '/getting-started/upgrade-guide');
Route::permanentRedirect('users', '/control-panel/users');
+Route::permanentRedirect('utilities', '/control-panel/utilities');
Route::permanentRedirect('upgrade-guide/v2-to-v3', '/getting-started/upgrade-guide/v2-to-v3');
Route::permanentRedirect('validation', '/content-modeling/validation');
Route::permanentRedirect('deploying/vercel', '/getting-started/deploying/vercel');
@@ -245,4 +249,4 @@
Route::permanentRedirect('repositories', '/backend-apis/resource-apis');
Route::permanentRedirect('new-antlers-parser', '/frontend/antlers');
Route::permanentRedirect('tips/storing-entries-in-a-database', '/knowledge-base/tips/storing-content-in-a-database');
-Route::permanentRedirect('account-api-sites', '/advanced-topics/sites-api');
\ No newline at end of file
+Route::permanentRedirect('account-api-sites', '/advanced-topics/sites-api');
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'));
diff --git a/tests/Feature/ServeMarkdownTest.php b/tests/Feature/ServeMarkdownTest.php
new file mode 100644
index 000000000..b1c6899f2
--- /dev/null
+++ b/tests/Feature/ServeMarkdownTest.php
@@ -0,0 +1,71 @@
+get('/control-panel/users', [
+ 'Accept' => '*/*',
+ ]);
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/html; charset=utf-8');
+ $this->assertStringContainsString('', strtolower($response->getContent()));
+ }
+
+ public function test_explicit_markdown_accept_returns_markdown(): void
+ {
+ $response = $this->get('/control-panel/users', [
+ 'Accept' => 'text/markdown',
+ ]);
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
+ $this->assertStringStartsWith('# Users', $response->getContent());
+ }
+
+ public function test_browser_accept_returns_html(): void
+ {
+ $response = $this->get('/control-panel/users', [
+ 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ ]);
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/html; charset=utf-8');
+ }
+
+ public function test_html_pages_include_markdown_alternate_link_header(): void
+ {
+ $response = $this->get('/control-panel/users', [
+ 'Accept' => 'text/html',
+ ]);
+
+ $response->assertOk();
+ $response->assertHeader('Vary', 'Accept');
+ $this->assertStringContainsString('rel="alternate"; type="text/markdown"', $response->headers->get('Link'));
+ }
+
+ public function test_legacy_url_with_markdown_accept_redirects_to_markdown_twin(): void
+ {
+ $response = $this->get('/users', [
+ 'Accept' => 'text/markdown',
+ ]);
+
+ $response->assertRedirect();
+ $this->assertStringEndsWith('/control-panel/users.md', $response->headers->get('Location'));
+ }
+
+ public function test_index_md_serves_home_page(): void
+ {
+ $response = $this->get('/index.md');
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
+ $this->assertStringStartsWith('# Home', $response->getContent());
+ }
+
+}
diff --git a/tests/Unit/MarkdownUrlTest.php b/tests/Unit/MarkdownUrlTest.php
new file mode 100644
index 000000000..644f82520
--- /dev/null
+++ b/tests/Unit/MarkdownUrlTest.php
@@ -0,0 +1,45 @@
+assertSame(
+ url('/tags/collection.md'),
+ MarkdownUrl::for('/tags/collection')
+ );
+ }
+
+ public function test_preserves_fragment_and_query(): void
+ {
+ $this->assertSame(
+ url('/tags/collection.md#parameters'),
+ MarkdownUrl::for('/tags/collection#parameters')
+ );
+
+ $this->assertSame(
+ url('/tags/collection.md?foo=bar'),
+ MarkdownUrl::for('/tags/collection?foo=bar')
+ );
+ }
+
+ public function test_home_page_uses_index_md(): void
+ {
+ $this->assertSame(
+ url('/index.md'),
+ MarkdownUrl::for('/')
+ );
+ }
+
+ public function test_returns_null_for_external_urls(): void
+ {
+ $this->assertNull(MarkdownUrl::for('https://example.com/docs'));
+ $this->assertNull(MarkdownUrl::for('mailto:hello@example.com'));
+ }
+
+}