Skip to content

Commit b4ec0f5

Browse files
committed
Replace special characters
1 parent ac8d36a commit b4ec0f5

5 files changed

Lines changed: 267 additions & 1 deletion

File tree

‎bin/generate-llms‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
use Light\App\Service\LlmsGenerator;
7+
8+
chdir(__DIR__ . '/../');
9+
10+
require 'vendor/autoload.php';
11+
12+
$container = require 'config/container.php';
13+
$llmsGenerator = $container->get(LlmsGenerator::class);
14+
15+
$count = $llmsGenerator->write();
16+
17+
printf(
18+
"Done. %d categor%s written to %s%s",
19+
$count,
20+
$count === 1 ? 'y' : 'ies',
21+
$llmsGenerator->getOutputFile(),
22+
PHP_EOL
23+
);

‎config/autoload/local.php.dist‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ return [
6767
'sourceDir' => realpath(__DIR__ . '/../../public/md-articles'),
6868
'outputFile' => realpath(__DIR__ . '/../../public/llms-full.txt'),
6969
// Markdown versions of the static pages, appended after the articles. Optional.
70-
'pagesDir' => realpath(__DIR__ . '/../../public/md-pages'),
70+
'pagesDir' => realpath(__DIR__ . '/../../public/md-pages'),
71+
// Short, categorized index of the blog, written by bin/generate-llms.
72+
'indexFile' => realpath(__DIR__ . '/../../public/llms.txt'),
7173
],
7274
/**
7375
* Credentials for `bin/generate-packages`. Reading public repositories needs no token

‎src/App/src/ConfigProvider.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Light\App\Factory\GetSitemapViewHandlerFactory;
2121
use Light\App\Factory\GitHubClientFactory;
2222
use Light\App\Factory\LlmsFullGeneratorFactory;
23+
use Light\App\Factory\LlmsGeneratorFactory;
2324
use Light\App\Factory\PackageGeneratorFactory;
2425
use Light\App\Factory\SitemapGeneratorFactory;
2526
use Light\App\Handler\GetFeedViewHandler;
@@ -32,6 +33,7 @@
3233
use Light\App\Service\GitHubClient;
3334
use Light\App\Service\GitHubClientInterface;
3435
use Light\App\Service\LlmsFullGenerator;
36+
use Light\App\Service\LlmsGenerator;
3537
use Light\App\Service\PackageGenerator;
3638
use Light\App\Service\SitemapGenerator;
3739
use Mezzio\Application;
@@ -141,6 +143,7 @@ public function getDependencies(): array
141143
GitHubClient::class => GitHubClientFactory::class,
142144
PackageGenerator::class => PackageGeneratorFactory::class,
143145
LlmsFullGenerator::class => LlmsFullGeneratorFactory::class,
146+
LlmsGenerator::class => LlmsGeneratorFactory::class,
144147
],
145148
'aliases' => [
146149
EntityManager::class => 'doctrine.entity_manager.orm_default',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Factory;
6+
7+
use Light\App\Service\LlmsGenerator;
8+
use Light\Blog\Repository\PostRepository;
9+
use Psr\Container\ContainerInterface;
10+
11+
use function assert;
12+
13+
class LlmsGeneratorFactory
14+
{
15+
public function __invoke(ContainerInterface $container): LlmsGenerator
16+
{
17+
$postRepository = $container->get(PostRepository::class);
18+
assert($postRepository instanceof PostRepository);
19+
20+
$config = $container->get('config');
21+
22+
return new LlmsGenerator(
23+
$postRepository,
24+
$config['llms']['sourceDir'],
25+
$config['llms']['indexFile'],
26+
$config['application']['url'] ?? '',
27+
);
28+
}
29+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Service;
6+
7+
use Light\Blog\Entity\Post;
8+
use Light\Blog\Repository\PostRepository;
9+
use RuntimeException;
10+
11+
use function array_key_exists;
12+
use function count;
13+
use function file_get_contents;
14+
use function file_put_contents;
15+
use function implode;
16+
use function preg_match;
17+
use function sprintf;
18+
use function strcasecmp;
19+
use function trim;
20+
use function uasort;
21+
use function usort;
22+
23+
24+
class LlmsGenerator
25+
{
26+
/** @var list<non-empty-string> */
27+
private const CATEGORY_ORDER = [
28+
'dotkernel',
29+
'php-development',
30+
'how-to',
31+
'best-practice',
32+
'dotkernel-api',
33+
'dotkernel3',
34+
'headless-platform',
35+
'zend-framework',
36+
'javascript',
37+
'middleware',
38+
'architecture',
39+
'php-troubleshooting',
40+
'phpstorm',
41+
'licensing',
42+
'design-pattern',
43+
];
44+
45+
/** @var array<non-empty-string, non-empty-string> */
46+
private const CATEGORY_BLURBS = [
47+
'dotkernel' => 'the core framework - releases, caching, sessions, auth, WURFL/device detection',
48+
'php-development' => 'general PHP tooling, environments, IDEs, security',
49+
'how-to' => 'practical guides - migrations, CORS, PSR-7, routing',
50+
'best-practice' => 'coding standards and database access patterns',
51+
'dotkernel-api' => 'REST API package - auth, content negotiation, OpenAPI',
52+
'dotkernel3' => 'the v3 rewrite on Zend Expressive/Mezzio',
53+
'headless-platform' => 'the current Dotkernel headless architecture',
54+
'zend-framework' => 'Zend Framework 1 history and end-of-life notes',
55+
'javascript' => 'frontend/JS topics',
56+
'middleware' => 'PSR-15 middleware and routing internals',
57+
'architecture' => 'request lifecycle and application bootstrapping',
58+
'php-troubleshooting' => 'fixes for common PHP setup issues',
59+
'phpstorm' => 'IDE setup',
60+
'licensing' => 'open-source license comparisons',
61+
'design-pattern' => 'naming conventions for PSR-15 handlers',
62+
];
63+
64+
public function __construct(
65+
private readonly PostRepository $postRepository,
66+
private readonly string $sourceDir,
67+
private readonly string $outputFile,
68+
private readonly string $baseUrl,
69+
) {
70+
}
71+
72+
public function getOutputFile(): string
73+
{
74+
return $this->outputFile;
75+
}
76+
77+
public function write(): int
78+
{
79+
80+
$postsByCategory = $this->groupPublishedPostsByCategory();
81+
82+
$sections = [];
83+
foreach (self::CATEGORY_ORDER as $slug) {
84+
if (! isset($postsByCategory[$slug])) {
85+
continue;
86+
}
87+
$sections[] = $this->buildCategorySection($slug, $postsByCategory[$slug]);
88+
unset($postsByCategory[$slug]);
89+
}
90+
91+
uasort($postsByCategory, static fn (array $a, array $b): int => count($b) - count($a));
92+
foreach ($postsByCategory as $slug => $posts) {
93+
$sections[] = $this->buildCategorySection($slug, $posts);
94+
}
95+
96+
$written = $this->buildHeader() . implode("\n", $sections);
97+
98+
if (file_put_contents($this->outputFile, $written) === false) {
99+
throw new RuntimeException('Unable to write llms.txt.');
100+
}
101+
102+
return count($sections);
103+
}
104+
105+
/**
106+
* @return array<non-empty-string, list<array{post: Post, title: string, description: string}>>
107+
*/
108+
private function groupPublishedPostsByCategory(): array
109+
{
110+
$byCategory = [];
111+
foreach ($this->postRepository->getPublishedPosts() as $post) {
112+
$slug = $post->getCategory()->getSlug();
113+
$frontMatter = $this->resolveFrontMatter($slug, $post);
114+
$byCategory[$slug][] = [
115+
'post' => $post,
116+
'title' => $frontMatter['title'],
117+
'description' => $frontMatter['description'],
118+
];
119+
}
120+
121+
foreach ($byCategory as $slug => $entries) {
122+
usort($entries, static fn (array $a, array $b): int => strcasecmp($a['title'], $b['title']));
123+
$byCategory[$slug] = $entries;
124+
}
125+
126+
return $byCategory;
127+
}
128+
129+
/**
130+
* @param list<array{post: Post, title: string, description: string}> $entries
131+
*/
132+
private function buildCategorySection(string $slug, array $entries): string
133+
{
134+
$category = $entries[0]['post']->getCategory();
135+
$heading = sprintf(
136+
'## %s (%d post%s)',
137+
$category->getName(),
138+
count($entries),
139+
count($entries) === 1 ? '' : 's',
140+
);
141+
142+
$lines = [$heading, ''];
143+
144+
if (array_key_exists($slug, self::CATEGORY_BLURBS)) {
145+
$lines[] = sprintf('*%s*', self::CATEGORY_BLURBS[$slug]);
146+
$lines[] = '';
147+
}
148+
149+
foreach ($entries as $entry) {
150+
$lines[] = sprintf(
151+
'- [%s](%s/%s/%s/): %s',
152+
$entry['title'],
153+
$this->baseUrl,
154+
$slug,
155+
$entry['post']->getSlug(),
156+
$entry['description'],
157+
);
158+
}
159+
160+
return implode("\n", $lines) . "\n";
161+
}
162+
163+
/**
164+
* @return array{title: string, description: string}
165+
*/
166+
private function resolveFrontMatter(string $categorySlug, Post $post): array
167+
{
168+
$result = ['title' => $post->getTitle(), 'description' => $post->getExcerpt()];
169+
$path = sprintf('%s/%s/%s.md', $this->sourceDir, $categorySlug, $post->getSlug());
170+
$contents = @file_get_contents($path);
171+
172+
if ($contents === false) {
173+
return $result;
174+
}
175+
176+
if (preg_match('/^title:\s*"(.*)"\s*$/m', $contents, $matches) === 1) {
177+
$result['title'] = trim($matches[1]);
178+
}
179+
180+
if (preg_match('/^description:\s*"(.*)"\s*$/m', $contents, $matches) === 1) {
181+
$result['description'] = trim($matches[1]);
182+
}
183+
184+
return $result;
185+
}
186+
187+
private function buildHeader(): string
188+
{
189+
return <<<HEADER
190+
# Dotkernel Light
191+
192+
> Dotkernel Light is the technical blog for the Dotkernel headless PHP platform - a PSR-15 compliant application built on Mezzio and Laminas components. It publishes architecture write-ups, how-tos, and release notes for the platform applications - Dotkernel API, Admin and Queue - and for the standalone Dotkernel Light skeleton.
193+
194+
Content spans foundational PHP/middleware architecture (PSR-7, PSR-15, request lifecycle, dependency injection), practical how-tos (Doctrine migrations, CORS, authentication, caching), and the history/release notes of the Dotkernel ecosystem going back to 2008. Posts are organized by category and attributed to an author; URLs follow the pattern `/{category-slug}/{post-slug}/`.
195+
196+
## Docs
197+
198+
- [Blog]({$this->baseUrl}/blog/): full list of posts, most recent first, paginated
199+
- [Categories]({$this->baseUrl}/categories/): all categories with post counts
200+
- [About]({$this->baseUrl}/about/): the team behind Dotkernel - how the team works, its commitment to open source and the PHP community, and how it uses AI under guardrails
201+
- [OSS Package Lifecycle]({$this->baseUrl}/dotkernel-packages-oss-lifecycle/): support/maintenance status of Dotkernel's open-source packages
202+
- [Contact]({$this->baseUrl}/contact/)
203+
204+
## Categories
205+
206+
207+
HEADER;
208+
}
209+
}

0 commit comments

Comments
 (0)