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