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