99use RuntimeException ;
1010
1111use function array_key_exists ;
12+ use function array_merge ;
1213use function basename ;
1314use function count ;
1415use function explode ;
@@ -28,16 +29,16 @@ class LlmsGenerator
2829{
2930 /** @var list<non-empty-string> */
3031 private const CATEGORY_ORDER = [
31- 'dotkernel ' ,
32- 'php-development ' ,
33- 'dotkernel-api ' ,
34- 'dotkernel3 ' ,
3532 'headless-platform ' ,
36- 'middleware ' ,
33+ 'dotkernel-api ' ,
3734 'architecture ' ,
38- 'design-pattern ' ,
3935 'how-to ' ,
36+ 'middleware ' ,
4037 'best-practice ' ,
38+ 'design-pattern ' ,
39+ 'dotkernel3 ' ,
40+ 'dotkernel ' ,
41+ 'php-development ' ,
4142 ];
4243
4344 /**
@@ -50,11 +51,40 @@ class LlmsGenerator
5051 'php-troubleshooting ' ,
5152 'licensing ' ,
5253 'phpstorm ' ,
54+ 'zend-framework ' ,
55+ ];
56+
57+ /**
58+ * Individual posts demoted to `## Optional`, keyed by category slug - for a single article
59+ * that doesn't warrant moving its whole category into `OPTIONAL_CATEGORIES`.
60+ *
61+ * @var array<non-empty-string, list<non-empty-string>>
62+ */
63+ private const array OPTIONAL_POSTS = [
64+ 'dotkernel ' => [
65+ 'commitment-to-php-new-zend-certified-engineers-zce-in-our-team ' ,
66+ 'adding-composer-support-in-your-dotkernel-project ' ,
67+ 'using-dotkernel-with-composer-dependencies ' ,
68+ 'forcing-utf8-connections-and-character-set-in-mysql ' ,
69+ 'adding-a-cors-implementation-to-zend-expressive ' ,
70+ ],
71+ 'best-practice ' => [
72+ 'insert-update-delete-statements-with-zend-db ' ,
73+ 'sql-queries-using-zend-db-select ' ,
74+ 'subqueries-with-zend-db ' ,
75+ 'using-like-wildcards-with-zend-db ' ,
76+ 'what-are-returning-the-fetch-functions-from-zend-db ' ,
77+ ],
78+ 'php-development ' => [
79+ 'almalinux-9-in-wsl2-install-php-apache-mariadb-composer-phpmyadmin ' ,
80+ 'mezzio-app-development-in-wsl2 ' ,
81+ ],
5382 ];
5483
5584 /** @var array<non-empty-string, non-empty-string> */
5685 private const CATEGORY_BLURBS = [
57- 'dotkernel ' => 'the core framework - releases, caching, sessions, auth, WURFL/device detection ' ,
86+ 'dotkernel ' => 'the core framework - dot-* components (caching, logging, mail, dependency injection), '
87+ . ' Doctrine integration, and getting started with Dotkernel Light ' ,
5888 'php-development ' => 'general PHP tooling, environments, IDEs, security ' ,
5989 'how-to ' => 'practical guides - migrations, CORS, PSR-7, routing ' ,
6090 'best-practice ' => 'coding standards and database access patterns ' ,
@@ -89,6 +119,15 @@ class LlmsGenerator
89119 */
90120 private const string OSS_LIFECYCLE_SLUG = 'dotkernel-packages-oss-lifecycle ' ;
91121
122+ /**
123+ * The Contact page has no `public/contact.md` counterpart to source a title/description
124+ * from, so it is hardcoded directly into `## Pages` instead.
125+ */
126+ private const string CONTACT_TITLE = 'Contact ' ;
127+ private const string CONTACT_PATH = 'contact/ ' ;
128+ private const string CONTACT_DESCRIPTION = 'Get in touch, report a security issue, or find where to ask a '
129+ . 'question and contribute to the project. ' ;
130+
92131 public function __construct (
93132 private readonly PostRepository $ postRepository ,
94133 private readonly string $ sourceDir ,
@@ -108,16 +147,20 @@ public function write(): int
108147 {
109148 $ postsByCategory = $ this ->groupPublishedPostsByCategory ();
110149
111- $ sections = [];
150+ $ sections = [];
151+ $ optionalLines = [];
112152 foreach (self ::CATEGORY_ORDER as $ slug ) {
113153 if (! isset ($ postsByCategory [$ slug ])) {
114154 continue ;
115155 }
116- $ sections [] = $ this ->buildCategorySection ($ slug , $ postsByCategory [$ slug ]);
156+ [$ entries , $ demoted ] = $ this ->splitOptionalPosts ($ slug , $ postsByCategory [$ slug ]);
157+ $ optionalLines = array_merge ($ optionalLines , $ demoted );
158+ if ($ entries !== []) {
159+ $ sections [] = $ this ->buildCategorySection ($ slug , $ entries );
160+ }
117161 unset($ postsByCategory [$ slug ]);
118162 }
119163
120- $ optionalLines = [];
121164 $ optionalCategoryCount = 0 ;
122165 foreach (self ::OPTIONAL_CATEGORIES as $ slug ) {
123166 if (! isset ($ postsByCategory [$ slug ])) {
@@ -132,7 +175,11 @@ public function write(): int
132175
133176 uasort ($ postsByCategory , static fn (array $ a , array $ b ): int => count ($ b ) - count ($ a ));
134177 foreach ($ postsByCategory as $ slug => $ posts ) {
135- $ sections [] = $ this ->buildCategorySection ($ slug , $ posts );
178+ [$ entries , $ demoted ] = $ this ->splitOptionalPosts ($ slug , $ posts );
179+ $ optionalLines = array_merge ($ optionalLines , $ demoted );
180+ if ($ entries !== []) {
181+ $ sections [] = $ this ->buildCategorySection ($ slug , $ entries );
182+ }
136183 }
137184
138185 $ written = $ this ->buildHeader ()
@@ -174,17 +221,40 @@ private function groupPublishedPostsByCategory(): array
174221 return $ byCategory ;
175222 }
176223
224+ /**
225+ * @param list<array{post: Post, title: string, description: string}> $entries
226+ * @return array{0: list<array{post: Post, title: string, description: string}>, 1: list<string>}
227+ */
228+ private function splitOptionalPosts (string $ slug , array $ entries ): array
229+ {
230+ $ optionalSlugs = self ::OPTIONAL_POSTS [$ slug ] ?? [];
231+ if ($ optionalSlugs === []) {
232+ return [$ entries , []];
233+ }
234+
235+ $ kept = [];
236+ $ optional = [];
237+ foreach ($ entries as $ entry ) {
238+ if (in_array ($ entry ['post ' ]->getSlug (), $ optionalSlugs , true )) {
239+ $ optional [] = $ this ->buildOptionalPostLink ($ slug , $ entry );
240+ continue ;
241+ }
242+
243+ $ kept [] = $ entry ;
244+ }
245+
246+ return [$ kept , $ optional ];
247+ }
248+
177249 /**
178250 * @param list<array{post: Post, title: string, description: string}> $entries
179251 */
180252 private function buildCategorySection (string $ slug , array $ entries ): string
181253 {
182254 $ category = $ entries [0 ]['post ' ]->getCategory ();
183255 $ heading = sprintf (
184- '## %s (%d post%s) ' ,
185- $ category ->getName (),
186- count ($ entries ),
187- count ($ entries ) === 1 ? '' : 's ' ,
256+ '## %s ' ,
257+ $ category ->getName ()
188258 );
189259
190260 $ lines = [$ heading , '' ];
@@ -256,7 +326,13 @@ private function buildPagesSection(): string
256326 return '' ;
257327 }
258328
259- $ pages = [];
329+ $ pages = [
330+ [
331+ 'title ' => self ::CONTACT_TITLE ,
332+ 'path ' => self ::CONTACT_PATH ,
333+ 'description ' => self ::CONTACT_DESCRIPTION ,
334+ ],
335+ ];
260336 foreach (glob ($ this ->pagesDir . '/*.md ' ) ?: [] as $ path ) {
261337 $ slug = basename ($ path , '.md ' );
262338 if ($ slug === self ::OSS_LIFECYCLE_SLUG || in_array ($ slug , self ::DOCS_PAGES , true )) {
@@ -276,24 +352,20 @@ private function buildPagesSection(): string
276352
277353 $ pages [] = [
278354 'title ' => trim (explode ('| ' , $ title , 2 )[0 ]),
279- 'slug ' => $ slug ,
355+ 'path ' => $ slug . ' .md ' ,
280356 'description ' => $ description ,
281357 ];
282358 }
283359
284- if ($ pages === []) {
285- return '' ;
286- }
287-
288360 usort ($ pages , static fn (array $ a , array $ b ): int => strcasecmp ($ a ['title ' ], $ b ['title ' ]));
289361
290362 $ lines = ['## Pages ' , '' ];
291363 foreach ($ pages as $ page ) {
292364 $ lines [] = sprintf (
293- '- [%s](%s/%s.md ): %s ' ,
365+ '- [%s](%s/%s): %s ' ,
294366 $ page ['title ' ],
295367 $ this ->baseUrl ,
296- $ page ['slug ' ],
368+ $ page ['path ' ],
297369 $ page ['description ' ],
298370 );
299371 }
@@ -358,9 +430,6 @@ private function buildHeader(): string
358430 . 'by category and attributed to an author; URLs follow the pattern `/{category-slug}/{post-slug}/`. '
359431 . 'Each post also has a Markdown version at `/{category-slug}/{post-slug}.md`. ' ;
360432
361- $ about = 'the team behind Dotkernel - how the team works, its commitment to open source and the PHP '
362- . 'community, and how it uses AI under guardrails ' ;
363-
364433 return "# {$ this ->title }\n\n"
365434 . $ intro . "\n\n"
366435 . $ body . "\n\n"
0 commit comments