Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/create-uploads-dir
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,4 @@ printf(
$imagesCopied === 1 ? '' : 's',
$imagesMissing,
PHP_EOL
);
);
23 changes: 23 additions & 0 deletions bin/generate-llms-full
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

use Light\App\Service\LlmsFullGenerator;

chdir(__DIR__ . '/../');

require 'vendor/autoload.php';

$container = require 'config/container.php';
$llmsFullGenerator = $container->get(LlmsFullGenerator::class);

$count = $llmsFullGenerator->write();

printf(
"Done. %d article%s written to %s%s",
$count,
$count === 1 ? '' : 's',
$llmsFullGenerator->getOutputFile(),
PHP_EOL
);
4 changes: 4 additions & 0 deletions config/autoload/local.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ return [
'feed' => [
'path' => realpath(__DIR__ . '/../../public/feed.xml'),
],
'llms' => [
'sourceDir' => realpath(__DIR__ . '/../../public/md-articles'),
'outputFile' => realpath(__DIR__ . '/../../public/llms-full.txt'),
],
/**
* Credentials for `bin/generate-packages`. Reading public repositories needs no token
* scopes at all; without a token the generator still runs, at a lower rate limit.
Expand Down
24 changes: 24 additions & 0 deletions public/md-articles/dotkernel/test-article.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: "Test article"
description: "This is a test article used for fixture and testing purposes."
author: "admin"
date_published: "2026-08-06"
canonical_url: "https://www.dotkernel.com/dotkernel/test-article/"
category: "Dotkernel"
language: "en"
---

# Test article

## TL;DR

This is a test article added to the dotkernel category for testing purposes.

## Overview

This is a test article used for fixture and testing purposes.

## FAQ

**Q: What is this article for?**
A: This is a test article added to the Dotkernel category for fixture and testing purposes.
Binary file added public/uploads/test-article.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/App/src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Light\App\Factory\GetPackagesViewHandlerFactory;
use Light\App\Factory\GetSitemapViewHandlerFactory;
use Light\App\Factory\GitHubClientFactory;
use Light\App\Factory\LlmsFullGeneratorFactory;
use Light\App\Factory\PackageGeneratorFactory;
use Light\App\Factory\SitemapGeneratorFactory;
use Light\App\Handler\GetFeedViewHandler;
Expand All @@ -30,6 +31,7 @@
use Light\App\Service\FeedGenerator;
use Light\App\Service\GitHubClient;
use Light\App\Service\GitHubClientInterface;
use Light\App\Service\LlmsFullGenerator;
use Light\App\Service\PackageGenerator;
use Light\App\Service\SitemapGenerator;
use Mezzio\Application;
Expand Down Expand Up @@ -138,6 +140,7 @@ public function getDependencies(): array
SitemapGenerator::class => SitemapGeneratorFactory::class,
GitHubClient::class => GitHubClientFactory::class,
PackageGenerator::class => PackageGeneratorFactory::class,
LlmsFullGenerator::class => LlmsFullGeneratorFactory::class,
],
'aliases' => [
EntityManager::class => 'doctrine.entity_manager.orm_default',
Expand Down
22 changes: 22 additions & 0 deletions src/App/src/Factory/LlmsFullGeneratorFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Light\App\Factory;

use Light\App\Service\LlmsFullGenerator;
use Psr\Container\ContainerInterface;

class LlmsFullGeneratorFactory
{
public function __invoke(ContainerInterface $container): LlmsFullGenerator
{
$config = $container->get('config');

return new LlmsFullGenerator(
$config['llms']['sourceDir'],
$config['llms']['outputFile'],
$config['application']['baseUrl'] ?? '',
);
}
}
13 changes: 13 additions & 0 deletions src/App/src/Fixture/articles_cleaned.json
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,19 @@
"opengraph_img": "\/opengraph\/article\/twitter-card-symfony-mailer.png",
"excerpt": "What prompted the change According to the discussion from the LaminasTechnical steering Committee of 2023-12-04, it was decided that the laminas\/laminas-mail package would be abandoned. On the one hand, there is nobody to maintain the package and on the other, there are several alternatives available in the ecosystem: ddeboer\/imap for interacting with IMAP zbateson\/mail-mime-parser for parsing MIME messages symfony\/mailer for sending mail How Dotkernel handles the issue The Dotkernel team has also opted to replace the laminas\/laminas-mail package in the dotkernel\/dot-mail package.",
"tl_dr": "The Laminas Technical Steering Committee decided on 2023-12-04 to abandon laminas\/laminas-mail.\nDotkernel responded by replacing it with symfony\/mailer inside the dotkernel\/dot-mail package (version 5), aiming for minimal impact on existing projects — calls to send mail stay the same, though mime and imap related functionality is removed."
},
{
"post_title": "Test article",
"post_date": "2026-08-06 00:00:00",
"post_status": "publish",
"author": {
"display_name": "stefan",
"github": "OStefan2001"
},
"isObsolete": false,
"opengraph_img": "\/opengraph\/article\/twitter-card-symfony-mailer.png",
"excerpt": "This is a test article used for fixture and testing purposes.",
"tl_dr": "This is a test article added to the dotkernel category for testing purposes."
}
]
},
Expand Down
96 changes: 96 additions & 0 deletions src/App/src/Service/LlmsFullGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace Light\App\Service;

use RuntimeException;

use function array_map;
use function array_merge;
use function count;
use function file_get_contents;
use function file_put_contents;
use function glob;
use function implode;
use function is_file;
use function ltrim;
use function sort;
use function sprintf;
use function str_replace;
use function trim;

/**
* Concatenates every article under the markdown source directory into a single
* plain-text file for LLM consumption, in the same format as llms.txt/llms-full.txt
* conventions (index.md first, then every other article sorted by relative path).
*/
readonly class LlmsFullGenerator
{
private const SEPARATOR = '<!-- ============================================================ -->';

public function __construct(
private string $sourceDir,
private string $outputFile,
private string $baseUrl,
) {
}

public function getOutputFile(): string
{
return $this->outputFile;
}

public function write(): int
{
$relativePaths = $this->collectRelativePaths();

$sections = array_map(
fn (string $relativePath): string => $this->buildSection($relativePath),
$relativePaths
);

$written = implode("\n\n\n", $sections) . "\n\n";

if (file_put_contents($this->outputFile, $written) === false) {
throw new RuntimeException('Unable to write llms-full.txt.');
}

return count($relativePaths);
}

/**
* @return list<string>
*/
private function collectRelativePaths(): array
{
$categoryFiles = glob($this->sourceDir . '/*/*.md') ?: [];
$categoryFiles = array_map(
fn (string $path): string => ltrim(str_replace($this->sourceDir, '', $path), '/'),
$categoryFiles
);
sort($categoryFiles);

$paths = is_file($this->sourceDir . '/index.md') ? ['index.md'] : [];

return array_merge($paths, $categoryFiles);
}

private function buildSection(string $relativePath): string
{
$contents = file_get_contents($this->sourceDir . '/' . $relativePath);
if ($contents === false) {
throw new RuntimeException("Unable to read file: {$relativePath}");
}

$contents = str_replace('{{base_url}}', $this->baseUrl, $contents);

return sprintf(
"%s\n<!-- %s -->\n%s\n\n%s",
self::SEPARATOR,
$relativePath,
self::SEPARATOR,
trim($contents)
);
}
}
49 changes: 49 additions & 0 deletions src/Blog/templates/page/JSON-LD/dotkernel/test-article.jsonld.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "TechArticle",
"@id": "{{ absolute_url(path('page::blog-resource', {categorySlug: article.category.slug, slug: article.slug})) }}#article",
"headline": {{ article.title|json_encode|raw }},
"description": {{ article.excerpt|json_encode|raw }},
"url": "{{ absolute_url(path('page::blog-resource', {categorySlug: article.category.slug, slug: article.slug})) }}",
"mainEntityOfPage": "{{ absolute_url(path('page::blog-resource', {categorySlug: article.category.slug, slug: article.slug})) }}",
"datePublished": "{{ article.postDate|date('c') }}",
"dateModified": "{{ article.postDate|date('c') }}",
"image": "{{ app.meta.image }}",
"articleSection": {{ article.category.name|json_encode|raw }},
"author": {
"@type": "Person",
"name": {{ article.author.name|json_encode|raw }},
"url": "{{ absolute_url(path('page::author-resource', {slug: article.author.slug})) }}"
},
"publisher": {
"@type": "Organization",
"name": "Dotkernel",
"url": "{{ absolute_url('/') }}",
"logo": {
"@type": "ImageObject",
"url": "{{ absolute_url(asset('images/app/dotkernel-logo.png')) }}"
}
}
},
{
"@type": "BreadcrumbList",
"@id": "{{ absolute_url(path('page::blog-resource', {categorySlug: article.category.slug, slug: article.slug})) }}#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "{{ absolute_url('/') }}" },
{ "@type": "ListItem", "position": 2, "name": {{ article.category.name|json_encode|raw }}, "item": "{{ absolute_url(path('page::category-resource', {slug: article.category.slug})) }}" },
{ "@type": "ListItem", "position": 3, "name": {{ article.title|json_encode|raw }} }
]
},
{
"@type": "FAQPage",
"@id": "{{ absolute_url(path('page::blog-resource', {categorySlug: article.category.slug, slug: article.slug})) }}#faq",
"mainEntity": [
{ "@type": "Question", "name": "What is this article for?", "acceptedAnswer": { "@type": "Answer", "text": "This is a test article added to the Dotkernel category for fixture and testing purposes." } }
]
}
]
}
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends '@layout/blog-post.html.twig' %}

{% block body %}
<img src="{{ asset('uploads/article/' ~ article.id ~ '/test-article.png') }}" alt="">

<p>This is a test article used for fixture and testing purposes.</p>

<h2 id="user-content-faq">Frequently Asked Questions</h2>

<div class="faq-list">
<details class="acc" open>
<summary>What is this article for? <span class="chev">+</span></summary>
<div class="acc-body">
<p class="mb-0">This is a test article added to the Dotkernel category for fixture and testing purposes.</p>
</div>
</details>
</div>
{% endblock %}