Skip to content

Commit 72e4a44

Browse files
authored
Merge pull request #133 from dotkernel/llms-generate
Updates to llms-generator
2 parents a06bddb + caa5088 commit 72e4a44

5 files changed

Lines changed: 221 additions & 34 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,8 @@ public/uploads/
5656
# Generated by bin/generate-packages (daily cron); not tracked so it never conflicts on pull
5757
/public/dotkernel-packages.json
5858
/public/dotkernel-packages.json.tmp
59+
60+
/public/llms-full.txt
61+
/public/llms.txt
62+
/public/feed.xml
63+
/public/sitemap.xml

src/App/src/Factory/LlmsGeneratorFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public function __invoke(ContainerInterface $container): LlmsGenerator
2424
$config['llms']['sourceDir'],
2525
$config['llms']['indexFile'],
2626
$config['application']['url'] ?? '',
27+
$config['llms']['pagesDir'] ?? null,
2728
);
2829
}
2930
}

src/App/src/Service/LlmsGenerator.php

Lines changed: 86 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
use RuntimeException;
1010

1111
use function array_key_exists;
12+
use function basename;
1213
use function count;
14+
use function explode;
1315
use function file_get_contents;
1416
use function file_put_contents;
17+
use function glob;
1518
use function implode;
1619
use function preg_match;
1720
use function sprintf;
@@ -65,6 +68,7 @@ public function __construct(
6568
private readonly string $sourceDir,
6669
private readonly string $outputFile,
6770
private readonly string $baseUrl,
71+
private readonly ?string $pagesDir = null,
6872
) {
6973
}
7074

@@ -91,7 +95,10 @@ public function write(): int
9195
$sections[] = $this->buildCategorySection($slug, $posts);
9296
}
9397

94-
$written = $this->buildHeader() . implode("\n", $sections);
98+
$written = $this->buildHeader()
99+
. $this->buildPagesSection()
100+
. "## Categories\n\n"
101+
. implode("\n", $sections);
95102

96103
if (file_put_contents($this->outputFile, $written) === false) {
97104
throw new RuntimeException('Unable to write llms.txt.');
@@ -163,53 +170,98 @@ private function buildCategorySection(string $slug, array $entries): string
163170
*/
164171
private function resolveFrontMatter(string $categorySlug, Post $post): array
165172
{
166-
$result = ['title' => $post->getTitle(), 'description' => $post->getExcerpt()];
167173
$path = sprintf('%s/%s/%s.md', $this->sourceDir, $categorySlug, $post->getSlug());
168174
$contents = @file_get_contents($path);
169175

170-
if ($contents === false) {
171-
return $result;
172-
}
176+
return [
177+
'title' => $contents === false
178+
? $post->getTitle()
179+
: $this->extractFrontMatterField($contents, 'title') ?? $post->getTitle(),
180+
'description' => $contents === false
181+
? $post->getExcerpt()
182+
: $this->extractFrontMatterField($contents, 'description') ?? $post->getExcerpt(),
183+
];
184+
}
173185

174-
if (preg_match('/^title:\s*"(.*)"\s*$/m', $contents, $matches) === 1) {
175-
$result['title'] = trim($matches[1]);
186+
private function buildPagesSection(): string
187+
{
188+
if ($this->pagesDir === null) {
189+
return '';
176190
}
177191

178-
if (preg_match('/^description:\s*"(.*)"\s*$/m', $contents, $matches) === 1) {
179-
$result['description'] = trim($matches[1]);
180-
}
192+
$pages = [];
193+
foreach (glob($this->pagesDir . '/*.md') ?: [] as $path) {
194+
$contents = file_get_contents($path);
195+
if ($contents === false) {
196+
continue;
197+
}
181198

182-
return $result;
183-
}
199+
$title = $this->extractFrontMatterField($contents, 'title');
200+
$description = $this->extractFrontMatterField($contents, 'description');
201+
if ($title === null || $description === null) {
202+
continue;
203+
}
184204

185-
private function buildHeader(): string
186-
{
187-
return <<<HEADER
188-
# Dotkernel Light
205+
$pages[] = [
206+
'title' => trim(explode('|', $title, 2)[0]),
207+
'slug' => basename($path, '.md'),
208+
'description' => $description,
209+
];
210+
}
189211

190-
> Dotkernel Light is the technical blog for the Dotkernel headless PHP platform - a PSR-15 compliant
191-
application built on Mezzio and Laminas components. It publishes architecture write-ups, how-tos,
192-
and release notes for the platform applications - Dotkernel API, Admin and Queue - and for the standalone
193-
Dotkernel Light skeleton.
212+
if ($pages === []) {
213+
return '';
214+
}
194215

195-
Content spans foundational PHP/middleware architecture (PSR-7, PSR-15, request lifecycle, dependency injection),
196-
practical how-tos (Doctrine migrations, CORS, authentication, caching), and the history/release notes of the
197-
Dotkernel ecosystem going back to 2008. Posts are organized by category and attributed to an author;
198-
URLs follow the pattern `/{category-slug}/{post-slug}/`.
216+
usort($pages, static fn (array $a, array $b): int => strcasecmp($a['title'], $b['title']));
199217

200-
## Docs
218+
$lines = ['## Pages', ''];
219+
foreach ($pages as $page) {
220+
$lines[] = sprintf(
221+
'- [%s](%s/%s/): %s',
222+
$page['title'],
223+
$this->baseUrl,
224+
$page['slug'],
225+
$page['description'],
226+
);
227+
}
201228

202-
- [Blog]({$this->baseUrl}/blog/): full list of posts, most recent first, paginated
203-
- [Categories]({$this->baseUrl}/categories/): all categories with post counts
204-
- [About]({$this->baseUrl}/about/): the team behind Dotkernel - how the team works, its commitment to open
205-
source and the PHP community, and how it uses AI under guardrails
206-
- [OSS Package Lifecycle]({$this->baseUrl}/dotkernel-packages-oss-lifecycle/): support/maintenance status of
207-
Dotkernel's open-source packages
208-
- [Contact]({$this->baseUrl}/contact/)
229+
return implode("\n", $lines) . "\n\n";
230+
}
209231

210-
## Categories
232+
private function extractFrontMatterField(string $contents, string $field): ?string
233+
{
234+
if (preg_match(sprintf('/^%s:\s*"(.*)"\s*$/m', $field), $contents, $matches) === 1) {
235+
return trim($matches[1]);
236+
}
211237

238+
return null;
239+
}
212240

213-
HEADER;
241+
private function buildHeader(): string
242+
{
243+
$intro = '> Dotkernel Light is the technical blog for the Dotkernel headless PHP platform - a PSR-15 '
244+
. 'compliant application built on Mezzio and Laminas components. It publishes architecture '
245+
. 'write-ups, how-tos, and release notes for the platform applications - Dotkernel API, Admin '
246+
. 'and Queue - and for the standalone Dotkernel Light skeleton.';
247+
248+
$body = 'Content spans foundational PHP/middleware architecture (PSR-7, PSR-15, request lifecycle, '
249+
. 'dependency injection), practical how-tos (Doctrine migrations, CORS, authentication, caching), '
250+
. 'and the history/release notes of the Dotkernel ecosystem going back to 2008. Posts are organized '
251+
. 'by category and attributed to an author; URLs follow the pattern `/{category-slug}/{post-slug}/`.';
252+
253+
$about = 'the team behind Dotkernel - how the team works, its commitment to open source and the PHP '
254+
. 'community, and how it uses AI under guardrails';
255+
256+
return "# Dotkernel Light\n\n"
257+
. $intro . "\n\n"
258+
. $body . "\n\n"
259+
. "## Docs\n\n"
260+
. "- [Blog]({$this->baseUrl}/blog/): full list of posts, most recent first, paginated\n"
261+
. "- [Categories]({$this->baseUrl}/categories/): all categories with post counts\n"
262+
. "- [About]({$this->baseUrl}/about/): {$about}\n"
263+
. "- [OSS Package Lifecycle]({$this->baseUrl}/dotkernel-packages-oss-lifecycle/): "
264+
. "support/maintenance status of Dotkernel's open-source packages\n"
265+
. "- [Contact]({$this->baseUrl}/contact/)\n\n";
214266
}
215267
}

test/Unit/App/Factory/LlmsGeneratorFactoryTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,35 @@ public function testInvokeAppliesTheConfiguredValues(): void
2424
'llms' => [
2525
'sourceDir' => '/tmp/md-articles',
2626
'indexFile' => '/tmp/llms.txt',
27+
'pagesDir' => '/tmp/md-pages',
2728
],
2829
'application' => ['url' => 'https://example.test'],
2930
]));
3031

3132
$this->assertSame('/tmp/llms.txt', $generator->getOutputFile());
3233
$this->assertSame('/tmp/md-articles', $this->readProperty($generator, 'sourceDir'));
34+
$this->assertSame('/tmp/md-pages', $this->readProperty($generator, 'pagesDir'));
3335
$this->assertSame('https://example.test', $this->readProperty($generator, 'baseUrl'));
3436
}
3537

38+
/**
39+
* A config predating the static-page markdown simply skips that section.
40+
*
41+
* @throws Exception
42+
*/
43+
public function testInvokeLeavesThePagesDirectoryUnsetWhenItIsNotConfigured(): void
44+
{
45+
$generator = (new LlmsGeneratorFactory())($this->createContainer([
46+
'llms' => [
47+
'sourceDir' => '/tmp/md-articles',
48+
'indexFile' => '/tmp/llms.txt',
49+
],
50+
'application' => ['url' => 'https://example.test'],
51+
]));
52+
53+
$this->assertNull($this->readProperty($generator, 'pagesDir'));
54+
}
55+
3656
/**
3757
* The application URL is read from `application.url` - the site's single URL key. A config
3858
* without it must not blow up, because the placeholder substitution is optional.

test/Unit/App/Service/LlmsGeneratorTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class LlmsGeneratorTest extends UnitTest
3232
{
3333
private string $workDir;
3434
private string $sourceDir;
35+
private string $pagesDir;
3536
private string $outputFile;
3637

3738
protected function setUp(): void
@@ -46,9 +47,11 @@ protected function setUp(): void
4647
);
4748

4849
$this->sourceDir = $this->workDir . DIRECTORY_SEPARATOR . 'md-articles';
50+
$this->pagesDir = $this->workDir . DIRECTORY_SEPARATOR . 'md-pages';
4951
$this->outputFile = $this->workDir . DIRECTORY_SEPARATOR . 'llms.txt';
5052

5153
mkdir($this->sourceDir, 0775, true);
54+
mkdir($this->pagesDir, 0775, true);
5255
}
5356

5457
protected function tearDown(): void
@@ -263,6 +266,101 @@ public function testWriteFallsBackToThePostEntityWhenTheArticleFileIsMissing():
263266
$this->assertStringContainsString(': Raw excerpt.', $output);
264267
}
265268

269+
/**
270+
* @throws Exception
271+
*/
272+
public function testWriteOmitsThePagesSectionWhenNoPagesDirectoryIsConfigured(): void
273+
{
274+
$this->writePage('api', 'Dotkernel API | Tagline');
275+
276+
$this->createGenerator(pagesDir: null)->write();
277+
278+
$this->assertStringNotContainsString('## Pages', $this->writtenOutput());
279+
}
280+
281+
/**
282+
* @throws Exception
283+
*/
284+
public function testWriteOmitsThePagesSectionWhenThePagesDirectoryIsEmpty(): void
285+
{
286+
$this->createGenerator()->write();
287+
288+
$this->assertStringNotContainsString('## Pages', $this->writtenOutput());
289+
}
290+
291+
/**
292+
* @throws Exception
293+
*/
294+
public function testWriteIncludesEachPageSortedAlphabeticallyByTitle(): void
295+
{
296+
$this->writePage('api', 'Zebra page | Some tagline');
297+
$this->writePage('admin', 'Alpha page | Some tagline');
298+
299+
$this->createGenerator(baseUrl: 'https://example.test')->write();
300+
301+
$output = $this->writtenOutput();
302+
$alphaAt = mb_strpos($output, '[Alpha page]');
303+
$zebraAt = mb_strpos($output, '[Zebra page]');
304+
305+
$this->assertStringContainsString('## Pages', $output);
306+
$this->assertNotFalse($alphaAt);
307+
$this->assertNotFalse($zebraAt);
308+
$this->assertLessThan($zebraAt, $alphaAt);
309+
}
310+
311+
/**
312+
* @throws Exception
313+
*/
314+
public function testWriteTrimsThePageTitleAtTheTaglineSeparator(): void
315+
{
316+
$this->writePage('api', 'Dotkernel API | Open-source REST API skeleton for PHP', 'The description.');
317+
318+
$this->createGenerator(baseUrl: 'https://example.test')->write();
319+
320+
$this->assertStringContainsString(
321+
'- [Dotkernel API](https://example.test/api/): The description.',
322+
$this->writtenOutput()
323+
);
324+
$this->assertStringNotContainsString('Open-source REST API skeleton for PHP]', $this->writtenOutput());
325+
}
326+
327+
/**
328+
* @throws Exception
329+
*/
330+
public function testWriteSkipsAPageMissingATitleOrDescription(): void
331+
{
332+
$this->writePage('api', 'Dotkernel API | Tagline', null);
333+
$this->writePage('admin', 'Dotkernel Admin | Tagline', 'Has a description.');
334+
335+
$this->createGenerator()->write();
336+
337+
$output = $this->writtenOutput();
338+
$this->assertStringNotContainsString('[Dotkernel API]', $output);
339+
$this->assertStringContainsString('[Dotkernel Admin]', $output);
340+
}
341+
342+
/**
343+
* @throws Exception
344+
*/
345+
public function testWritePlacesThePagesSectionBetweenDocsAndCategories(): void
346+
{
347+
$this->writePage('api', 'Dotkernel API | Tagline');
348+
$posts = [$this->createPost('A post', 'a-post', 'dotkernel', 'Dotkernel')];
349+
350+
$this->createGenerator($posts)->write();
351+
352+
$output = $this->writtenOutput();
353+
$docsAt = mb_strpos($output, '## Docs');
354+
$pagesAt = mb_strpos($output, '## Pages');
355+
$categoriesAt = mb_strpos($output, '## Categories');
356+
357+
$this->assertNotFalse($docsAt);
358+
$this->assertNotFalse($pagesAt);
359+
$this->assertNotFalse($categoriesAt);
360+
$this->assertLessThan($pagesAt, $docsAt);
361+
$this->assertLessThan($categoriesAt, $pagesAt);
362+
}
363+
266364
/**
267365
* @throws Exception
268366
*/
@@ -298,6 +396,7 @@ private function createGenerator(
298396
array $posts = [],
299397
?string $outputFile = null,
300398
string $baseUrl = 'https://example.test',
399+
?string $pagesDir = '',
301400
): LlmsGenerator {
302401
$postRepository = $this->createStub(PostRepository::class);
303402
$postRepository->method('getPublishedPosts')->willReturn($posts);
@@ -307,6 +406,7 @@ private function createGenerator(
307406
$this->sourceDir,
308407
$outputFile ?? $this->outputFile,
309408
$baseUrl,
409+
$pagesDir === '' ? $this->pagesDir : $pagesDir,
310410
);
311411
}
312412

@@ -346,6 +446,15 @@ private function writeArticle(string $categorySlug, string $slug, string $title,
346446
);
347447
}
348448

449+
private function writePage(string $slug, string $title, ?string $description = 'A description.'): void
450+
{
451+
$frontMatter = $description === null
452+
? sprintf("---\ntitle: \"%s\"\n---\n\nBody.\n", $title)
453+
: sprintf("---\ntitle: \"%s\"\ndescription: \"%s\"\n---\n\nBody.\n", $title, $description);
454+
455+
file_put_contents($this->pagesDir . DIRECTORY_SEPARATOR . $slug . '.md', $frontMatter);
456+
}
457+
349458
private function writtenOutput(): string
350459
{
351460
$contents = file_get_contents($this->outputFile);

0 commit comments

Comments
 (0)