diff --git a/app/modules/blog/src/AppBlogServiceProvider.php b/app/modules/blog/src/AppBlogServiceProvider.php index c6d2dda1..0a333b8e 100644 --- a/app/modules/blog/src/AppBlogServiceProvider.php +++ b/app/modules/blog/src/AppBlogServiceProvider.php @@ -5,6 +5,8 @@ namespace Drupal\app_blog; use Drupal\app_blog\Controller\BlogList; +use Drupal\app_blog\Llms\ArticleLlmsSubscriber; +use Drupal\app_blog\Llms\BlogListLlmsSubscriber; use Drupal\app_blog\Controller\RssFeed; use Drupal\app_blog\Controller\RssFeedRedirect; use Drupal\app_blog\Controller\RssFeedStylesheet; @@ -102,6 +104,10 @@ public function register(ContainerBuilder $container): void { // HTML processor. $autowire(HtmlProcessor::class); + + // Llms. + $autowire(ArticleLlmsSubscriber::class); + $autowire(BlogListLlmsSubscriber::class); } } diff --git a/app/modules/blog/src/Controller/BlogList.php b/app/modules/blog/src/Controller/BlogList.php index 648b5a53..9f1b6176 100644 --- a/app/modules/blog/src/Controller/BlogList.php +++ b/app/modules/blog/src/Controller/BlogList.php @@ -17,6 +17,12 @@ public function __construct( protected LanguageManagerInterface $languageManager, ) {} + public function load(): array { + $ids = $this->getEntityIds(); + + return $this->entityTypeManager->getStorage('node')->loadMultiple($ids); + } + protected function getItems(): array { $items = []; @@ -37,12 +43,6 @@ protected function buildPager(): array { ]; } - protected function load(): array { - $ids = $this->getEntityIds(); - - return $this->entityTypeManager->getStorage('node')->loadMultiple($ids); - } - protected function getEntityIds(): array { $query = $this ->entityTypeManager diff --git a/app/modules/blog/src/Llms/ArticleLlmsSubscriber.php b/app/modules/blog/src/Llms/ArticleLlmsSubscriber.php new file mode 100644 index 00000000..258ff237 --- /dev/null +++ b/app/modules/blog/src/Llms/ArticleLlmsSubscriber.php @@ -0,0 +1,103 @@ +routeMatch->getRouteName() !== 'entity.node.canonical') { + return; + } + + $node = $event->routeMatch->getParameter('node'); + + if (!$node instanceof ArticleBundle) { + return; + } + + $event->addCacheableDependency($node); + + $title = $node->getTitle(); + if ($title !== NULL) { + $event->setTitle($title); + } + + $parts = []; + $parts[] = $this->buildMeta($node, $event); + $parts[] = $this->buildContent($node); + + $event->setMarkdown(\implode("\n\n", \array_filter($parts))); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + private function buildMeta(ArticleBundle $node, LlmsRenderEvent $event): string { + $lines = []; + + $date_label = (string) $this->translation->translate('Date'); + $created = \date('Y-m-d', (int) $node->getCreatedTime()); + $lines[] = \sprintf('- **%s**: %s', $date_label, $created); + + $readTime = $node->getEstimatedReadTime(); + if ($readTime > 0) { + $read_time_label = (string) $this->translation->translate('Estimated read time'); + $min_label = (string) $this->translation->translate('@count min', ['@count' => $readTime]); + $lines[] = \sprintf('- **%s**: %s', $read_time_label, $min_label); + } + + $tags = $node->get('field_tags')->referencedEntities(); + if ($tags !== []) { + $tags_label = (string) $this->translation->translate('Tags'); + $lines[] = \sprintf('- **%s**:', $tags_label); + + foreach ($tags as $term) { + $event->addCacheableDependency($term); + $url = $term->toUrl()->toString(); + $lines[] = \sprintf(' - [%s](%s)', $term->label(), $url); + } + } + + return \implode("\n", $lines); + } + + private function buildContent(ArticleBundle $node): ?string { + $content = $node->getContent(); + + if ($content === NULL) { + return NULL; + } + + $build = [ + '#type' => 'processed_text', + '#text' => $content, + '#format' => 'blog_article', + ]; + + $html = (string) $this->renderer->renderInIsolation($build); + + if ($html === '') { + return NULL; + } + + return $this->markdownConverter->convert($html); + } + +} diff --git a/app/modules/blog/src/Llms/BlogListLlmsSubscriber.php b/app/modules/blog/src/Llms/BlogListLlmsSubscriber.php new file mode 100644 index 00000000..30defb2b --- /dev/null +++ b/app/modules/blog/src/Llms/BlogListLlmsSubscriber.php @@ -0,0 +1,61 @@ +routeMatch->getRouteName() !== 'app_blog.blog_list') { + return; + } + + $listCache = new CacheableMetadata(); + $listCache->addCacheTags(['node_list:blog_entry']); + $event->addCacheableDependency($listCache); + + $lines = []; + + foreach ($this->blogList->load() as $node) { + if (!($node instanceof ArticleBundle)) { + continue; + } + + $event->addCacheableDependency($node); + $lines[] = $this->formatArticle($node); + } + + $event->setMarkdown(\implode("\n", $lines)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + private function formatArticle(ArticleBundle $node): string { + $url = $node->toUrl()->toString(); + $title = $node->getTitle() ?? ''; + $date = \date('Y-m-d', (int) $node->getCreatedTime()); + $body = $node->get('body')->getString(); + + $line = \sprintf('- [%s](%s) (%s)', $title, $url, $date); + if ($body !== '') { + $line .= \sprintf(" \\\n %s", $body); + } + + return $line; + } + +} diff --git a/app/modules/blog/src/Node/ArticleBundle.php b/app/modules/blog/src/Node/ArticleBundle.php index f6f3f9aa..48152616 100644 --- a/app/modules/blog/src/Node/ArticleBundle.php +++ b/app/modules/blog/src/Node/ArticleBundle.php @@ -40,7 +40,7 @@ public function getContent(): ?string { return NULL; } - return $this->get('field_content')->getString(); + return $this->get('field_content')->first()?->get('value')->getString(); } public function getSourcePathHash(): ?string { diff --git a/app/modules/main/src/AppMainServiceProvider.php b/app/modules/main/src/AppMainServiceProvider.php index 3c0f3f28..292192fe 100644 --- a/app/modules/main/src/AppMainServiceProvider.php +++ b/app/modules/main/src/AppMainServiceProvider.php @@ -4,6 +4,7 @@ namespace Drupal\app_main; +use Drupal\app_main\Llms\HomeLlmsSubscriber; use Drupal\app_main\Navigation\SiteMap\MainMenuSiteMap; use Drupal\app_main\Navigation\Toolbar\ContentEditingToolbarLinksBuilder; use Drupal\app_main\StaticPage\About\Repository\AboutSettings; @@ -30,6 +31,8 @@ public function register(ContainerBuilder $container): void { $autowire(ServicesSettings::class); $autowire(SupportSettings::class); + $autowire(HomeLlmsSubscriber::class); + $container->register(ContentEditingToolbarLinksBuilder::class) ->setPublic(TRUE) ->addArgument(new Reference('plugin.manager.menu.local_task')) diff --git a/app/modules/main/src/Llms/HomeLlmsSubscriber.php b/app/modules/main/src/Llms/HomeLlmsSubscriber.php new file mode 100644 index 00000000..2a8d11a6 --- /dev/null +++ b/app/modules/main/src/Llms/HomeLlmsSubscriber.php @@ -0,0 +1,120 @@ +routeMatch->getRouteName() !== 'app_main.home') { + return; + } + + $sections = $event->mainContent['#sections'] ?? []; + $parts = []; + + if (isset($sections['home_intro'])) { + $parts[] = (string) $sections['home_intro']['#heading']; + } + + $parts[] = $this->buildLinks(); + + foreach (['latest_posts', 'too_big_to_read', 'most_discussed'] as $section_id) { + $section_markdown = $this->buildSection($sections, $section_id); + + if ($section_markdown === NULL) { + continue; + } + + $parts[] = $section_markdown; + } + + $event->setMarkdown(\implode("\n\n", $parts)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + /** + * @param array $sections + */ + private function buildSection(array $sections, string $section_id): ?string { + if (!isset($sections[$section_id])) { + return NULL; + } + + $section = $sections[$section_id]; + \assert(\is_array($section)); + $items = $section['#items'] ?? []; + \assert(\is_array($items)); + $ids = \array_keys($items); + + if ($ids === []) { + return NULL; + } + + $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($ids); + $lines = $this->formatArticles($nodes); + + if ($lines === []) { + return NULL; + } + + $heading = $section['#heading'] ?? ''; + \assert(\is_string($heading) || $heading instanceof \Stringable); + $heading = (string) $heading; + + return \sprintf("## %s\n\n%s", $heading, \implode("\n", $lines)); + } + + private function buildLinks(): string { + $about = Url::fromRoute('app_main.about')->toString(); + $blog = Url::fromRoute('app_blog.blog_list')->toString(); + + return \sprintf( + "- [%s](%s)\n- [%s](%s)", + $this->translation->translate('All publications'), + $blog, + $this->translation->translate('About the author'), + $about, + ); + } + + /** + * @param array $nodes + * + * @return list + */ + private function formatArticles(array $nodes): array { + $lines = []; + + foreach ($nodes as $node) { + if (!$node instanceof ArticleBundle) { + continue; + } + + $url = $node->toUrl()->toString(); + $title = $node->getTitle() ?? ''; + $date = \date('Y-m-d', (int) $node->getCreatedTime()); + $lines[] = \sprintf('- [%s](%s) (%s)', $title, $url, $date); + } + + return $lines; + } + +} diff --git a/app/modules/platform/composer.json b/app/modules/platform/composer.json index 240b24dd..822d9018 100644 --- a/app/modules/platform/composer.json +++ b/app/modules/platform/composer.json @@ -7,6 +7,7 @@ ], "require": { "app/contract": "^1.0", - "app-asset/photoswipe": "^5.4" + "app-asset/photoswipe": "^5.4", + "league/html-to-markdown": "^5.1" } } diff --git a/app/modules/platform/src/AppPlatformServiceProvider.php b/app/modules/platform/src/AppPlatformServiceProvider.php index b59615c5..6e4c4238 100644 --- a/app/modules/platform/src/AppPlatformServiceProvider.php +++ b/app/modules/platform/src/AppPlatformServiceProvider.php @@ -9,7 +9,14 @@ use Drupal\app_platform\Console\Process\ProcessGit; use Drupal\app_platform\Console\Process\ProcessTerminal; use Drupal\app_platform\Hook\Asset\CacheBustingQuerySetting; +use Drupal\app_platform\Hook\Core\LlmsPageAttachments; use Drupal\app_platform\Hook\Theme\LibraryInfoAlter; +use Drupal\app_platform\Llms\EventSubscriber\LlmsFooterSubscriber; +use Drupal\app_platform\Llms\EventSubscriber\PagerLlmsSubscriber; +use Drupal\app_platform\Llms\HtmlToMarkdownConverter; +use Drupal\app_platform\Llms\LlmsRenderer; +use Drupal\app_platform\Llms\Middleware\LlmsRequestLogger; +use Drupal\app_platform\Llms\PathProcessor\LlmsFormatPathProcessor; use Drupal\app_platform\LanguageAwareStore\EventSubscriber\LanguageAwareSettingsRoutes; use Drupal\app_platform\LanguageAwareStore\Factory\DatabaseLanguageAwareFactory; use Drupal\app_platform\LanguageAwareStore\Factory\ServiceContainerLanguageAwareFactory; @@ -68,10 +75,23 @@ public function register(ContainerBuilder $container): void { id: 'logger.channel.app_platform', definition: new ChildDefinition('logger.channel_base')->addArgument('app_platform'), ); + $container->setDefinition( + id: 'logger.channel.app_platform.llms', + definition: new ChildDefinition('logger.channel_base')->addArgument('app_platform.llms'), + ); + + // Llms. + $autowire(HtmlToMarkdownConverter::class); + $autowire(LlmsRenderer::class); + $autowire(PagerLlmsSubscriber::class); + $autowire(LlmsFooterSubscriber::class); + $autowire(LlmsFormatPathProcessor::class); + $autowire(LlmsRequestLogger::class); // Hooks. $autowire(CacheBustingQuerySetting::class); $autowire(LibraryInfoAlter::class); + $autowire(LlmsPageAttachments::class); } diff --git a/app/modules/platform/src/Hook/Core/LlmsPageAttachments.php b/app/modules/platform/src/Hook/Core/LlmsPageAttachments.php new file mode 100644 index 00000000..e81c76a2 --- /dev/null +++ b/app/modules/platform/src/Hook/Core/LlmsPageAttachments.php @@ -0,0 +1,53 @@ +routeMatch->getRouteObject(); + + if (!$route || $route->getOption('_admin_route')) { + return; + } + + $llmsUrl = $this->requestStack->getCurrentRequest()?->getPathInfo() . '?_wrapper_format=llms'; + + $attachments['#attached']['html_head'][] = [ + [ + '#type' => 'html_tag', + '#tag' => 'script', + '#attributes' => ['type' => 'text/llms.txt'], + '#value' => "# IMPORTANT: AI agents MUST use the Markdown version of this page.\n# Append ?_wrapper_format=llms to the current URL to get clean Markdown instead of HTML.\n# DO NOT parse raw HTML — use the Markdown version.", + ], + 'llms_script', + ]; + + $attachments['#attached']['html_head'][] = [ + [ + '#type' => 'html_tag', + '#tag' => 'link', + '#attributes' => [ + 'rel' => 'alternate', + 'type' => 'text/markdown', + 'href' => $llmsUrl, + 'title' => 'LLM-optimized Markdown version', + ], + ], + 'llms_alternate', + ]; + } + +} diff --git a/app/modules/platform/src/Llms/EventSubscriber/LlmsFooterSubscriber.php b/app/modules/platform/src/Llms/EventSubscriber/LlmsFooterSubscriber.php new file mode 100644 index 00000000..ee96d608 --- /dev/null +++ b/app/modules/platform/src/Llms/EventSubscriber/LlmsFooterSubscriber.php @@ -0,0 +1,61 @@ +buildCanonicalLink($event), + $this->buildIndexLink(), + ]; + + $event->append("\n\n---\n\n" . \implode("\n", $lines)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsResponseAlterEvent::class => ['onAlter', -100]]; + } + + private function buildCanonicalLink(LlmsResponseAlterEvent $event): string { + // Build the canonical (human-readable) URL for this page. + // We use getPathInfo() for the path because it contains the path alias. + // Url::fromRoute() with 'path_processing' => FALSE returns the system + // path (/node/123) instead of the alias. + // For query parameters we read the original QUERY_STRING from the server + // because $request->query has already been modified by inbound path + // processors (e.g., PagerPathProcessor converts page=3 to page=2). + $url = $event->request->getPathInfo(); + + $queryString = $event->request->server->get('QUERY_STRING', ''); + \assert(\is_string($queryString)); + \parse_str($queryString, $query); + unset($query['_wrapper_format']); + + if ($query !== []) { + $url .= '?' . \http_build_query($query); + } + + $label = (string) $this->translation->translate('View page'); + + return \sprintf('- [%s](%s)', $label, $url); + } + + private function buildIndexLink(): string { + $label = (string) $this->translation->translate('Site index'); + + return \sprintf('- [%s](/llms.txt)', $label); + } + +} diff --git a/app/modules/platform/src/Llms/EventSubscriber/PagerLlmsSubscriber.php b/app/modules/platform/src/Llms/EventSubscriber/PagerLlmsSubscriber.php new file mode 100644 index 00000000..69a65758 --- /dev/null +++ b/app/modules/platform/src/Llms/EventSubscriber/PagerLlmsSubscriber.php @@ -0,0 +1,70 @@ +pagerManager->getPager(); + + if ($pager === NULL || $pager->getTotalPages() <= 1) { + return; + } + + $current = $pager->getCurrentPage(); + $total = $pager->getTotalPages(); + $last = $total - 1; + + $heading = (string) $this->translation->translate('Navigation'); + $page_status = (string) $this->translation->translate('Page @current of @total', [ + '@current' => $current + 1, + '@total' => $total, + ]); + + $lines = []; + $lines[] = "\n\n---\n"; + $lines[] = \sprintf("## %s\n", $heading); + $lines[] = \sprintf("%s\n", $page_status); + + if ($current > 0) { + $lines[] = \sprintf('- [%s](%s)', $this->translation->translate('First page'), $this->buildPageUrl(0)); + $lines[] = \sprintf('- [%s](%s)', $this->translation->translate('Previous page'), $this->buildPageUrl($current - 1)); + } + + if ($current < $last) { + $lines[] = \sprintf('- [%s](%s)', $this->translation->translate('Next page'), $this->buildPageUrl($current + 1)); + $lines[] = \sprintf('- [%s](%s)', $this->translation->translate('Last page'), $this->buildPageUrl($last)); + } + + $event->append(\implode("\n", $lines)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsResponseAlterEvent::class => 'onAlter']; + } + + private function buildPageUrl(int $page): string { + $options = []; + + if ($page > 0) { + $options['query']['page'] = $page; + } + + return Url::fromRoute('', options: $options)->toString(); + } + +} diff --git a/app/modules/platform/src/Llms/HtmlToMarkdownConverter.php b/app/modules/platform/src/Llms/HtmlToMarkdownConverter.php new file mode 100644 index 00000000..0cbdf1bf --- /dev/null +++ b/app/modules/platform/src/Llms/HtmlToMarkdownConverter.php @@ -0,0 +1,21 @@ + TRUE, + 'header_style' => 'atx', + 'remove_nodes' => 'script style nav form iframe', + ]); + + return $converter->convert($html); + } + +} diff --git a/app/modules/platform/src/Llms/LlmsRenderEvent.php b/app/modules/platform/src/Llms/LlmsRenderEvent.php new file mode 100644 index 00000000..1a0d5735 --- /dev/null +++ b/app/modules/platform/src/Llms/LlmsRenderEvent.php @@ -0,0 +1,55 @@ +cacheableMetadata = new CacheableMetadata(); + } + + public function addCacheableDependency(CacheableDependencyInterface $dependency): void { + $this->cacheableMetadata = $this->cacheableMetadata->merge(CacheableMetadata::createFromObject($dependency)); + } + + public function getCacheableMetadata(): CacheableMetadata { + return $this->cacheableMetadata; + } + + public function setMarkdown(string $markdown): void { + $this->markdown = $markdown; + } + + public function getMarkdown(): ?string { + return $this->markdown; + } + + public function hasCustomMarkdown(): bool { + return $this->markdown !== NULL; + } + + public function setTitle(string $title): void { + $this->title = $title; + } + + public function getTitle(): ?string { + return $this->title; + } + +} diff --git a/app/modules/platform/src/Llms/LlmsRenderer.php b/app/modules/platform/src/Llms/LlmsRenderer.php new file mode 100644 index 00000000..1cbda64b --- /dev/null +++ b/app/modules/platform/src/Llms/LlmsRenderer.php @@ -0,0 +1,91 @@ + 'llms'])] +final readonly class LlmsRenderer implements MainContentRendererInterface { + + public function __construct( + private TitleResolverInterface $titleResolver, + private RendererInterface $renderer, + private HtmlToMarkdownConverter $htmlToMarkdownConverter, + private EventDispatcherInterface $eventDispatcher, + ) {} + + #[\Override] + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match): CacheableResponse { + $renderEvent = new LlmsRenderEvent($main_content, $request, $route_match); + $this->eventDispatcher->dispatch($renderEvent); + + $cacheableMetadata = CacheableMetadata::createFromRenderArray($main_content); + + $cacheableMetadata = $cacheableMetadata->merge($renderEvent->getCacheableMetadata()); + + if ($renderEvent->hasCustomMarkdown()) { + $markdown = $renderEvent->getMarkdown() ?? ''; + } + else { + $renderContext = new RenderContext(); + $rendered = $this->renderer->executeInRenderContext($renderContext, function () use (&$main_content): string { + return (string) $this->renderer->render($main_content); + }); + \assert(\is_string($rendered)); + $markup = $rendered; + + if (!$renderContext->isEmpty()) { + $bubbleable = $renderContext->pop(); + \assert($bubbleable instanceof CacheableMetadata); + $cacheableMetadata = $cacheableMetadata->merge($bubbleable); + } + + $markdown = $this->htmlToMarkdownConverter->convert($markup); + } + + $title = $renderEvent->getTitle() ?? $this->resolveTitle($main_content, $request, $route_match); + + if ($title !== '') { + $markdown = "# {$title}\n\n{$markdown}"; + } + + $alterEvent = new LlmsResponseAlterEvent($markdown, $request, $route_match); + $this->eventDispatcher->dispatch($alterEvent); + + $response = new CacheableResponse($alterEvent->getMarkdown(), 200, [ + 'Content-Type' => 'text/markdown; charset=UTF-8', + 'X-Robots-Tag' => 'noindex', + ]); + $response->addCacheableDependency($cacheableMetadata); + + return $response; + } + + private function resolveTitle(array $main_content, Request $request, RouteMatchInterface $route_match): string { + $route = $route_match->getRouteObject(); + + if ($route === NULL) { + return ''; + } + + $title = $this->titleResolver->getTitle($request, $route); + + if ($title === NULL || \is_array($title)) { + return ''; + } + + return (string) $title; + } + +} diff --git a/app/modules/platform/src/Llms/LlmsResponseAlterEvent.php b/app/modules/platform/src/Llms/LlmsResponseAlterEvent.php new file mode 100644 index 00000000..329905bf --- /dev/null +++ b/app/modules/platform/src/Llms/LlmsResponseAlterEvent.php @@ -0,0 +1,35 @@ +markdown; + } + + public function setMarkdown(string $markdown): void { + $this->markdown = $markdown; + } + + public function prepend(string $content): void { + $this->markdown = $content . $this->markdown; + } + + public function append(string $content): void { + $this->markdown .= $content; + } + +} diff --git a/app/modules/platform/src/Llms/Middleware/LlmsRequestLogger.php b/app/modules/platform/src/Llms/Middleware/LlmsRequestLogger.php new file mode 100644 index 00000000..c5350a45 --- /dev/null +++ b/app/modules/platform/src/Llms/Middleware/LlmsRequestLogger.php @@ -0,0 +1,40 @@ + 210])] +final readonly class LlmsRequestLogger implements HttpKernelInterface { + + public function __construct( + private HttpKernelInterface $httpKernel, + #[Autowire(service: 'logger.channel.app_platform.llms')] + private LoggerInterface $logger, + ) {} + + #[\Override] + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = TRUE): Response { + $response = $this->httpKernel->handle($request, $type, $catch); + + if ($type === self::MAIN_REQUEST && $request->query->get('_wrapper_format') === 'llms') { + $this->logger->info('LLMS request', [ + 'path' => $request->getPathInfo(), + 'user_agent' => $request->headers->get('User-Agent', ''), + 'referer' => $request->headers->get('Referer', ''), + 'status' => $response->getStatusCode(), + 'cache' => $response->headers->get('X-Drupal-Cache', 'NONE'), + ]); + } + + return $response; + } + +} diff --git a/app/modules/platform/src/Llms/PathProcessor/LlmsFormatPathProcessor.php b/app/modules/platform/src/Llms/PathProcessor/LlmsFormatPathProcessor.php new file mode 100644 index 00000000..eb41c200 --- /dev/null +++ b/app/modules/platform/src/Llms/PathProcessor/LlmsFormatPathProcessor.php @@ -0,0 +1,33 @@ +requestStack->getCurrentRequest(); + + if ($currentRequest === NULL || $currentRequest->query->get('_wrapper_format') !== 'llms') { + return $path; + } + + $options['query']['_wrapper_format'] = 'llms'; + + return $path; + } + +} diff --git a/app/modules/portfolio/src/AppPortfolioServiceProvider.php b/app/modules/portfolio/src/AppPortfolioServiceProvider.php index 866879c3..e5108e6b 100644 --- a/app/modules/portfolio/src/AppPortfolioServiceProvider.php +++ b/app/modules/portfolio/src/AppPortfolioServiceProvider.php @@ -5,6 +5,8 @@ namespace Drupal\app_portfolio; use Drupal\app_portfolio\Controller\PortfolioList; +use Drupal\app_portfolio\Llms\PortfolioListLlmsSubscriber; +use Drupal\app_portfolio\Llms\PortfolioLlmsSubscriber; use Drupal\app_portfolio\Repository\PortfolioSettings; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ServiceProviderInterface; @@ -20,6 +22,8 @@ public function register(ContainerBuilder $container): void { $autowire(PortfolioSettings::class); $autowire(PortfolioList::class); + $autowire(PortfolioListLlmsSubscriber::class); + $autowire(PortfolioLlmsSubscriber::class); } } diff --git a/app/modules/portfolio/src/Controller/PortfolioList.php b/app/modules/portfolio/src/Controller/PortfolioList.php index 8536f224..40e73fdd 100644 --- a/app/modules/portfolio/src/Controller/PortfolioList.php +++ b/app/modules/portfolio/src/Controller/PortfolioList.php @@ -15,6 +15,17 @@ public function __construct( private PortfolioSettings $settings, ) {} + /** + * @return \Drupal\node\NodeInterface[] + * The nodes. + */ + public function load(): array { + return $this + ->entityTypeManager + ->getStorage('node') + ->loadMultiple($this->getEntityIds()); + } + protected function buildItems(): array { $view_builder = $this->entityTypeManager->getViewBuilder('node'); $items = []; @@ -26,17 +37,6 @@ protected function buildItems(): array { return $items; } - /** - * @return \Drupal\node\NodeInterface[] - * The nodes. - */ - protected function load(): array { - return $this - ->entityTypeManager - ->getStorage('node') - ->loadMultiple($this->getEntityIds()); - } - protected function getEntityIds(): array { return $this ->entityTypeManager diff --git a/app/modules/portfolio/src/Llms/PortfolioListLlmsSubscriber.php b/app/modules/portfolio/src/Llms/PortfolioListLlmsSubscriber.php new file mode 100644 index 00000000..2b345f72 --- /dev/null +++ b/app/modules/portfolio/src/Llms/PortfolioListLlmsSubscriber.php @@ -0,0 +1,82 @@ +routeMatch->getRouteName() !== 'app_portfolio.portfolio_list') { + return; + } + + $listCache = new CacheableMetadata(); + $listCache->addCacheTags(['node_list:portfolio']); + $event->addCacheableDependency($listCache); + + $lines = $this->buildProjectLines($event); + $event->setMarkdown(\implode("\n", $lines)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + /** + * @return list + */ + private function buildProjectLines(LlmsRenderEvent $event): array { + $lines = []; + + foreach ($this->portfolioList->load() as $node) { + if (!$node instanceof PortfolioBundle) { + continue; + } + + $event->addCacheableDependency($node); + $lines[] = $this->formatProject($node); + } + + return $lines; + } + + private function formatProject(PortfolioBundle $node): string { + $url = $node->toUrl()->toString(); + $title = $node->getTitle() ?? ''; + $year = $node->getYearOfCompletion(); + + $line = \sprintf('- [%s](%s)', $title, $url); + + if ($year !== NULL) { + $line .= \sprintf(' (%s)', $year); + } + + $categories = $node->getCategories(); + + if ($categories !== []) { + $names = \array_map( + static fn ($term): string => (string) $term->label(), + $categories, + ); + $label = (string) $this->translation->translate('Categories'); + $line .= \sprintf(" \\\n **%s**: %s", $label, \implode(', ', $names)); + } + + return $line; + } + +} diff --git a/app/modules/portfolio/src/Llms/PortfolioLlmsSubscriber.php b/app/modules/portfolio/src/Llms/PortfolioLlmsSubscriber.php new file mode 100644 index 00000000..89a5e560 --- /dev/null +++ b/app/modules/portfolio/src/Llms/PortfolioLlmsSubscriber.php @@ -0,0 +1,126 @@ +routeMatch->getRouteName() !== 'entity.node.canonical') { + return; + } + + $node = $event->routeMatch->getParameter('node'); + + if (!$node instanceof PortfolioBundle) { + return; + } + + $event->addCacheableDependency($node); + + $title = $node->getTitle(); + if ($title !== NULL) { + $event->setTitle($title); + } + + $parts = []; + $parts[] = $this->buildMeta($node); + $parts[] = $this->buildDescription($node); + $parts[] = $this->buildImages($node); + + $event->setMarkdown(\implode("\n\n", \array_filter($parts))); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + private function buildMeta(PortfolioBundle $node): string { + $lines = []; + + $year = $node->getYearOfCompletion(); + if ($year !== NULL) { + $year_label = (string) $this->translation->translate('Year'); + $lines[] = \sprintf('- **%s**: %s', $year_label, $year); + } + + $projectUrl = $node->getProjectUrl(); + if ($projectUrl !== NULL) { + $url_label = (string) $this->translation->translate('Project URL'); + $lines[] = \sprintf('- **%s**: [%s](%s)', $url_label, $projectUrl, $projectUrl); + } + + $categories = $node->getCategories(); + if ($categories !== []) { + $cat_label = (string) $this->translation->translate('Categories'); + $lines[] = \sprintf('- **%s**:', $cat_label); + + foreach ($categories as $term) { + $lines[] = \sprintf(' - %s', $term->label()); + } + } + + return \implode("\n", $lines); + } + + private function buildDescription(PortfolioBundle $node): ?string { + $body = $node->get('body')->getString(); + + return $body !== '' ? $body : NULL; + } + + private function buildImages(PortfolioBundle $node): ?string { + $media_items = $node->get('field_media_images')->referencedEntities(); + + if ($media_items === []) { + return NULL; + } + + $label = (string) $this->translation->translate('Screenshots'); + $lines = [\sprintf('## %s', $label)]; + + foreach ($media_items as $media) { + $line = $this->buildImageLine($media); + + if ($line === NULL) { + continue; + } + + $lines[] = $line; + } + + return \implode("\n", $lines); + } + + private function buildImageLine(mixed $media): ?string { + if (!$media instanceof MediaInterface) { + return NULL; + } + + $uri = MediaHelper::getFileUri($media); + + if ($uri === NULL) { + return NULL; + } + + $url = $this->fileUrlGenerator->generateAbsoluteString($uri); + + return \sprintf('![%s](%s)', $media->getName(), $url); + } + +} diff --git a/app/modules/tag/src/AppTagServiceProvider.php b/app/modules/tag/src/AppTagServiceProvider.php index 48ec373c..5457884b 100644 --- a/app/modules/tag/src/AppTagServiceProvider.php +++ b/app/modules/tag/src/AppTagServiceProvider.php @@ -8,6 +8,8 @@ use Drupal\app_contract\Contract\Tag\TagUsageStatistics; use Drupal\app_tag\Controller\TagList; use Drupal\app_tag\EventSubscriber\RouteAlter; +use Drupal\app_tag\Llms\TagListLlmsSubscriber; +use Drupal\app_tag\Llms\TagPageLlmsSubscriber; use Drupal\app_tag\EventSubscriber\TermPageBuild; use Drupal\app_tag\Repository\DatabaseTagRepository; use Drupal\app_tag\Repository\DatabaseTagUsageStatistics; @@ -38,6 +40,8 @@ public function register(ContainerBuilder $container): void { $autowire(RouteAlter::class); $autowire(TermPageBuild::class); $autowire(TagSiteMap::class); + $autowire(TagListLlmsSubscriber::class); + $autowire(TagPageLlmsSubscriber::class); } } diff --git a/app/modules/tag/src/Llms/TagListLlmsSubscriber.php b/app/modules/tag/src/Llms/TagListLlmsSubscriber.php new file mode 100644 index 00000000..f50de8ec --- /dev/null +++ b/app/modules/tag/src/Llms/TagListLlmsSubscriber.php @@ -0,0 +1,63 @@ +routeMatch->getRouteName() !== 'app_tag.tag_list') { + return; + } + + $listCache = new CacheableMetadata(); + $listCache->addCacheTags(['taxonomy_term_list']); + $event->addCacheableDependency($listCache); + + $usage = $this->statistics->usage(); + $ids = \array_keys($usage); + + if ($ids === []) { + return; + } + + $terms = $this->entityTypeManager->getStorage('taxonomy_term')->loadMultiple($ids); + $lines = []; + + foreach ($terms as $term) { + $event->addCacheableDependency($term); + $count = (int) ($usage[$term->id()]->count ?? 0); + $lines[] = $this->formatTag($term, $count); + } + + $event->setMarkdown(\implode("\n", $lines)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + private function formatTag(TermInterface $term, int $count): string { + $url = $term->toUrl()->toString(); + $label = (string) $this->translation->translate('@count publications', ['@count' => $count]); + + return \sprintf('- [%s](%s) (%s)', $term->label(), $url, $label); + } + +} diff --git a/app/modules/tag/src/Llms/TagPageLlmsSubscriber.php b/app/modules/tag/src/Llms/TagPageLlmsSubscriber.php new file mode 100644 index 00000000..96dcc2d4 --- /dev/null +++ b/app/modules/tag/src/Llms/TagPageLlmsSubscriber.php @@ -0,0 +1,77 @@ +routeMatch->getRouteName() !== 'entity.taxonomy_term.canonical') { + return; + } + + $term = $event->routeMatch->getParameter('taxonomy_term'); + + if (!$term instanceof TagBundle) { + return; + } + + $event->addCacheableDependency($term); + + $listCache = new CacheableMetadata(); + $listCache->addCacheTags(['node_list']); + $event->addCacheableDependency($listCache); + + $items = $event->mainContent['#items'] ?? []; + + if (!\is_array($items) || $items === []) { + return; + } + + $lines = $this->buildArticleLines($event, \array_keys($items)); + $event->setMarkdown(\implode("\n", $lines)); + } + + #[\Override] + public static function getSubscribedEvents(): array { + return [LlmsRenderEvent::class => 'onLlmsRender']; + } + + /** + * Builds markdown lines for articles referenced by the given node IDs. + * + * @return list + * The formatted markdown lines. + */ + private function buildArticleLines(LlmsRenderEvent $event, array $ids): array { + $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($ids); + $lines = []; + + foreach ($nodes as $node) { + if (!$node instanceof ArticleBundle) { + continue; + } + + $event->addCacheableDependency($node); + $url = $node->toUrl()->toString(); + $title = $node->getTitle() ?? ''; + $date = \date('Y-m-d', (int) $node->getCreatedTime()); + $lines[] = \sprintf('- [%s](%s) (%s)', $title, $url, $date); + } + + return $lines; + } + +} diff --git a/app/themes/laszlo/components/content/home-intro/home-intro.twig b/app/themes/laszlo/components/content/home-intro/home-intro.twig index b5139439..7e3a9edf 100644 --- a/app/themes/laszlo/components/content/home-intro/home-intro.twig +++ b/app/themes/laszlo/components/content/home-intro/home-intro.twig @@ -18,7 +18,7 @@ {% include 'laszlo:icon' with { icon: 'documents-bold' } only %} {% endblock %} {% block children %} - {{ 'All posts'|t }} + {{ 'All publications'|t }} {% endblock %} {% endembed %} @@ -35,7 +35,7 @@ {% include 'laszlo:icon' with { icon: 'user-id-bold-duotone' } only %} {% endblock %} {% block children %} - {{ 'About'|t({}, {'context': 'author'}) }} + {{ 'About the author'|t }} {% endblock %} {% endembed %} diff --git a/assets/scaffold/llms.txt b/assets/scaffold/llms.txt new file mode 100644 index 00000000..4acecca3 --- /dev/null +++ b/assets/scaffold/llms.txt @@ -0,0 +1,24 @@ +# Niklan.net + +> Personal blog of Nikita Malyshev (Niklan), a Drupal web developer. The site is primarily in Russian. It features articles about web development, Drupal, PHP, and related technologies. + +## How to access content + +Every page on this site supports a Markdown version optimized for LLMs. Append `?_wrapper_format=llms` to any page URL to get clean Markdown instead of HTML. + +Example: `https://niklan.net/blog/123?_wrapper_format=llms` + +## Pages + +- [Blog](/blog): Articles about web development, Drupal, PHP, and technology. Paginated list of all articles. +- [Tags](/tags): Browse articles by topic tags with usage statistics. +- [Portfolio](/portfolio): Portfolio of completed projects. +- [About](/about): About the author — Nikita Malyshev, freelance Drupal web developer. +- [Services](/services): Information about available services and collaboration. +- [Contact](/contact): Contact information and ways to get in touch. +- [Support](/support): Ways to support the author. + +## Optional + +- [Blog RSS Feed](/blog.xml): RSS feed for blog articles. +- [Search](/search?q=QUERY): Site search. Replace QUERY with your search term. diff --git a/assets/scaffold/monolog.services.yml b/assets/scaffold/monolog.services.yml index 5c9949c9..7152a828 100644 --- a/assets/scaffold/monolog.services.yml +++ b/assets/scaffold/monolog.services.yml @@ -51,6 +51,10 @@ parameters: handlers: - name: rotating_file.app_blog.sync formatter: 'json' + app_platform.llms: + handlers: + - name: rotating_file.app_platform.llms + formatter: 'json' services: @@ -89,4 +93,7 @@ services: arguments: [ '../var/log/telegram.log', 7 ] monolog.handler.rotating_file.app_blog.sync: class: Monolog\Handler\RotatingFileHandler - arguments: [ '../var/log/blog-sync.log', 7 ] \ No newline at end of file + arguments: [ '../var/log/blog-sync.log', 7 ] + monolog.handler.rotating_file.app_platform.llms: + class: Monolog\Handler\RotatingFileHandler + arguments: [ '../var/log/llms.log', 7 ] \ No newline at end of file diff --git a/assets/translation/ru.po b/assets/translation/ru.po index 7c2d5bc2..b4072026 100644 --- a/assets/translation/ru.po +++ b/assets/translation/ru.po @@ -511,4 +511,61 @@ msgid "Visit website" msgstr "Перейти на сайт" msgid "Recent posts" -msgstr "Последние публикации" \ No newline at end of file +msgstr "Последние публикации" + +msgid "Navigation" +msgstr "Навигация" + +msgid "Page @current of @total" +msgstr "Страница @current из @total" + +msgid "First page" +msgstr "Первая страница" + +msgid "Previous page" +msgstr "Предыдущая страница" + +msgid "Next page" +msgstr "Следующая страница" + +msgid "Last page" +msgstr "Последняя страница" + +msgid "Date" +msgstr "Дата" + +msgid "Estimated read time" +msgstr "Примерное время чтения" + +msgid "@count min" +msgstr "@count мин" + +msgid "Tags" +msgstr "Теги" + +msgid "About the author" +msgstr "Об авторе" + +msgid "All publications" +msgstr "Все публикации" + +msgid "Categories" +msgstr "Категории" + +msgid "Year" +msgstr "Год" + +msgid "Project URL" +msgstr "URL проекта" + +msgid "Screenshots" +msgstr "Скриншоты" + +msgid "@count publications" +msgstr "@count публикаций" + +msgid "View page" +msgstr "Просмотр страницы" + +msgid "Site index" +msgstr "Карта сайта" \ No newline at end of file diff --git a/composer.json b/composer.json index 9a66ee36..a50a8a5e 100644 --- a/composer.json +++ b/composer.json @@ -27,14 +27,14 @@ } ], "require": { + "php": ">=8.4", "app/main": "^1.0@dev", "composer/installers": "^2.0", "cweagans/composer-patches": "^1.6", "drupal/core-composer-scaffold": "~11.3.0", "drupal/core-recommended": "~11.3.0", "drush/drush": "^13", - "niklan/composer-directories": "^1.0@alpha", - "php": ">=8.4" + "niklan/composer-directories": "^1.0@alpha" }, "require-dev": { "chi-teck/drupal-coder-extension": "^2.0@alpha", @@ -92,6 +92,7 @@ "overwrite": false }, "[project-root]/recipes/README.txt": false, + "[web-root]/llms.txt": "assets/scaffold/llms.txt", "[web-root]/robots.txt": { "append": "assets/scaffold/robots-txt-additions.txt" }, diff --git a/composer.lock b/composer.lock index 91d58e93..05af25fa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a69827c63de49480cc8d4da60ca1a044", + "content-hash": "0b66083f7d15392f2d6565aa7618eee3", "packages": [ { "name": "app-asset/alpinejs", @@ -253,11 +253,12 @@ "dist": { "type": "path", "url": "app/modules/platform", - "reference": "8636033bb6c2fe72d9dc2e2a5f57a4d4b1bb7c30" + "reference": "2dcfe97539bbc54cb1b3b26f099ac0d5229f301a" }, "require": { "app-asset/photoswipe": "^5.4", - "app/contract": "^1.0" + "app/contract": "^1.0", + "league/html-to-markdown": "^5.1" }, "type": "drupal-custom-module", "keywords": [ @@ -4514,6 +4515,95 @@ ], "time": "2025-05-20T12:55:37+00:00" }, + { + "name": "league/html-to-markdown", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/html-to-markdown.git", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "mikehaertl/php-shellcommand": "^1.1.0", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^8.5 || ^9.2", + "scrutinizer/ocular": "^1.6", + "unleashedtech/php-coding-standard": "^2.7 || ^3.0", + "vimeo/psalm": "^4.22 || ^5.0" + }, + "bin": [ + "bin/html-to-markdown" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\HTMLToMarkdown\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + }, + { + "name": "Nick Cernis", + "email": "nick@cern.is", + "homepage": "http://modernnerd.net", + "role": "Original Author" + } + ], + "description": "An HTML-to-markdown conversion helper for PHP", + "homepage": "https://github.com/thephpleague/html-to-markdown", + "keywords": [ + "html", + "markdown" + ], + "support": { + "issues": "https://github.com/thephpleague/html-to-markdown/issues", + "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown", + "type": "tidelift" + } + ], + "time": "2023-07-12T21:21:09+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", diff --git a/config/cspell/dictionary.txt b/config/cspell/dictionary.txt index 8075922f..233d53ec 100644 --- a/config/cspell/dictionary.txt +++ b/config/cspell/dictionary.txt @@ -9,7 +9,9 @@ Dflydev doctag fulltext hljs +llms Inlines +Malyshev laszlo Laszlo lightbox