diff --git a/config/pipeline.php b/config/pipeline.php index ba6c27c..4aeb423 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -4,6 +4,7 @@ use Dot\ErrorHandler\ErrorHandlerInterface; use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware; +use Light\App\Middleware\ServiceDocLinkMiddleware; use Mezzio\Application; use Mezzio\Handler\NotFoundHandler; use Mezzio\Helper\ServerUrlMiddleware; @@ -41,7 +42,6 @@ // Register the routing middleware in the middleware pipeline. // This middleware registers the Mezzio\Router\RouteResult request attribute. $app->pipe(RouteMiddleware::class); - $app->pipe(ResponseHeaderMiddleware::class); // The following handle routing failures for common conditions: // - HEAD request but no routes answer that method @@ -52,6 +52,8 @@ $app->pipe(ImplicitHeadMiddleware::class); $app->pipe(ImplicitOptionsMiddleware::class); $app->pipe(MethodNotAllowedMiddleware::class); + $app->pipe(ServiceDocLinkMiddleware::class); + $app->pipe(ResponseHeaderMiddleware::class); // Seed the UrlHelper with the routing results: $app->pipe(UrlHelperMiddleware::class); diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 965448c..59e0e74 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -24,12 +24,14 @@ use Light\App\Factory\MarkdownExtensionFactory; use Light\App\Factory\MarkdownRuntimeLoaderFactory; use Light\App\Factory\PackageGeneratorFactory; +use Light\App\Factory\ServiceDocLinkMiddlewareFactory; use Light\App\Factory\SitemapGeneratorFactory; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; +use Light\App\Middleware\ServiceDocLinkMiddleware; use Light\App\Resolver\EntityListenerResolver; use Light\App\Service\FeedGenerator; use Light\App\Service\GitHubClient; @@ -150,6 +152,7 @@ public function getDependencies(): array LlmsGenerator::class => LlmsGeneratorFactory::class, MarkdownExtension::class => MarkdownExtensionFactory::class, RuntimeLoaderInterface::class => MarkdownRuntimeLoaderFactory::class, + ServiceDocLinkMiddleware::class => ServiceDocLinkMiddlewareFactory::class, ], 'aliases' => [ EntityManager::class => 'doctrine.entity_manager.orm_default', diff --git a/src/App/src/Factory/ServiceDocLinkMiddlewareFactory.php b/src/App/src/Factory/ServiceDocLinkMiddlewareFactory.php new file mode 100644 index 0000000..4215c7c --- /dev/null +++ b/src/App/src/Factory/ServiceDocLinkMiddlewareFactory.php @@ -0,0 +1,16 @@ +; rel="service-doc"` header (RFC 8631) on pages that have a dedicated + * documentation page on docs.dotkernel.org, pointing at that page's own docs. + */ +class ServiceDocLinkMiddleware implements MiddlewareInterface +{ + private const array ROUTE_DOCS = [ + 'app::index' => 'https://docs.dotkernel.org/', + 'page::api' => 'https://docs.dotkernel.org/api-documentation/', + 'page::admin' => 'https://docs.dotkernel.org/admin-documentation/', + 'page::queue' => 'https://docs.dotkernel.org/queue-documentation/', + 'page::light' => 'https://docs.dotkernel.org/light-documentation/', + 'page::frontend' => 'https://docs.dotkernel.org/frontend/', + 'page::wsl2' => 'https://docs.dotkernel.org/development/v2/terminal/', + 'page::architecture' => 'https://docs.dotkernel.org/api-documentation/', + ]; + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + + $routeResult = $request->getAttribute(RouteResult::class); + if (! $routeResult instanceof RouteResult || ! $routeResult->isSuccess()) { + return $response; + } + + $routeName = $routeResult->getMatchedRouteName(); + if ($routeName === false || ! array_key_exists($routeName, self::ROUTE_DOCS)) { + return $response; + } + + $serviceDocLink = sprintf('<%s>; rel="service-doc"', self::ROUTE_DOCS[$routeName]); + $existingLink = $response->getHeaderLine('Link'); + + // A single `Link` header line with comma-separated values (RFC 8288 section 3), matching + // the style the `'*'` dot_response_headers entry already uses for llms-txt/llms-full-txt. + return $response->withHeader( + 'Link', + $existingLink === '' ? $serviceDocLink : $existingLink . ', ' . $serviceDocLink + ); + } +} diff --git a/test/Unit/App/Middleware/ServiceDocLinkMiddlewareTest.php b/test/Unit/App/Middleware/ServiceDocLinkMiddlewareTest.php new file mode 100644 index 0000000..a331677 --- /dev/null +++ b/test/Unit/App/Middleware/ServiceDocLinkMiddlewareTest.php @@ -0,0 +1,127 @@ +createMock(ResponseInterface::class); + $response->method('getHeaderLine')->with('Link')->willReturn(''); + $response + ->expects($this->once()) + ->method('withHeader') + ->with('Link', '; rel="service-doc"') + ->willReturn($response); + + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $routeResult = $this->createStub(RouteResult::class); + $routeResult->method('isSuccess')->willReturn(true); + $routeResult->method('getMatchedRouteName')->willReturn('page::api'); + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getAttribute')->willReturn($routeResult); + + $middleware = new ServiceDocLinkMiddleware(); + $result = $middleware->process($request, $handler); + + $this->assertSame($response, $result); + } + + /** + * @throws Exception + */ + public function testCombinesWithAnExistingLinkHeaderIntoOneLine(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getHeaderLine')->with('Link')->willReturn( + '; rel="llms-txt", ; rel="llms-full-txt"' + ); + $response + ->expects($this->once()) + ->method('withHeader') + ->with( + 'Link', + '; rel="llms-txt", ; rel="llms-full-txt", ' + . '; rel="service-doc"' + ) + ->willReturn($response); + + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $routeResult = $this->createStub(RouteResult::class); + $routeResult->method('isSuccess')->willReturn(true); + $routeResult->method('getMatchedRouteName')->willReturn('app::index'); + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getAttribute')->willReturn($routeResult); + + $middleware = new ServiceDocLinkMiddleware(); + $result = $middleware->process($request, $handler); + + $this->assertSame($response, $result); + } + + /** + * @throws Exception + */ + public function testLeavesResponseUntouchedForRouteWithNoDedicatedDocs(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->never())->method('withHeader'); + + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $routeResult = $this->createStub(RouteResult::class); + $routeResult->method('isSuccess')->willReturn(true); + $routeResult->method('getMatchedRouteName')->willReturn('page::dotboost'); + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getAttribute')->willReturn($routeResult); + + $middleware = new ServiceDocLinkMiddleware(); + $result = $middleware->process($request, $handler); + + $this->assertSame($response, $result); + } + + /** + * @throws Exception + */ + public function testLeavesResponseUntouchedWhenRoutingFailed(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->never())->method('withHeader'); + + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($response); + + $routeResult = $this->createStub(RouteResult::class); + $routeResult->method('isSuccess')->willReturn(false); + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getAttribute')->willReturn($routeResult); + + $middleware = new ServiceDocLinkMiddleware(); + $result = $middleware->process($request, $handler); + + $this->assertSame($response, $result); + } +}