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,28 @@ 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+ ],
5370 ];
5471
5572 /** @var array<non-empty-string, non-empty-string> */
5673 private const CATEGORY_BLURBS = [
57- 'dotkernel ' => 'the core framework - releases, caching, sessions, auth, WURFL/device detection ' ,
74+ 'dotkernel ' => 'the core framework - dot-* components (caching, logging, mail, dependency injection), '
75+ . ' Doctrine integration, and getting started with Dotkernel Light ' ,
5876 'php-development ' => 'general PHP tooling, environments, IDEs, security ' ,
5977 'how-to ' => 'practical guides - migrations, CORS, PSR-7, routing ' ,
6078 'best-practice ' => 'coding standards and database access patterns ' ,
@@ -89,6 +107,15 @@ class LlmsGenerator
89107 */
90108 private const string OSS_LIFECYCLE_SLUG = 'dotkernel-packages-oss-lifecycle ' ;
91109
110+ /**
111+ * The Contact page has no `public/contact.md` counterpart to source a title/description
112+ * from, so it is hardcoded directly into `## Pages` instead.
113+ */
114+ private const string CONTACT_TITLE = 'Contact ' ;
115+ private const string CONTACT_PATH = 'contact/ ' ;
116+ private const string CONTACT_DESCRIPTION = 'Get in touch, report a security issue, or find where to ask a '
117+ . 'question and contribute to the project. ' ;
118+
92119 public function __construct (
93120 private readonly PostRepository $ postRepository ,
94121 private readonly string $ sourceDir ,
@@ -108,16 +135,20 @@ public function write(): int
108135 {
109136 $ postsByCategory = $ this ->groupPublishedPostsByCategory ();
110137
111- $ sections = [];
138+ $ sections = [];
139+ $ optionalLines = [];
112140 foreach (self ::CATEGORY_ORDER as $ slug ) {
113141 if (! isset ($ postsByCategory [$ slug ])) {
114142 continue ;
115143 }
116- $ sections [] = $ this ->buildCategorySection ($ slug , $ postsByCategory [$ slug ]);
144+ [$ entries , $ demoted ] = $ this ->splitOptionalPosts ($ slug , $ postsByCategory [$ slug ]);
145+ $ optionalLines = array_merge ($ optionalLines , $ demoted );
146+ if ($ entries !== []) {
147+ $ sections [] = $ this ->buildCategorySection ($ slug , $ entries );
148+ }
117149 unset($ postsByCategory [$ slug ]);
118150 }
119151
120- $ optionalLines = [];
121152 $ optionalCategoryCount = 0 ;
122153 foreach (self ::OPTIONAL_CATEGORIES as $ slug ) {
123154 if (! isset ($ postsByCategory [$ slug ])) {
@@ -132,7 +163,11 @@ public function write(): int
132163
133164 uasort ($ postsByCategory , static fn (array $ a , array $ b ): int => count ($ b ) - count ($ a ));
134165 foreach ($ postsByCategory as $ slug => $ posts ) {
135- $ sections [] = $ this ->buildCategorySection ($ slug , $ posts );
166+ [$ entries , $ demoted ] = $ this ->splitOptionalPosts ($ slug , $ posts );
167+ $ optionalLines = array_merge ($ optionalLines , $ demoted );
168+ if ($ entries !== []) {
169+ $ sections [] = $ this ->buildCategorySection ($ slug , $ entries );
170+ }
136171 }
137172
138173 $ written = $ this ->buildHeader ()
@@ -174,17 +209,40 @@ private function groupPublishedPostsByCategory(): array
174209 return $ byCategory ;
175210 }
176211
212+ /**
213+ * @param list<array{post: Post, title: string, description: string}> $entries
214+ * @return array{0: list<array{post: Post, title: string, description: string}>, 1: list<string>}
215+ */
216+ private function splitOptionalPosts (string $ slug , array $ entries ): array
217+ {
218+ $ optionalSlugs = self ::OPTIONAL_POSTS [$ slug ] ?? [];
219+ if ($ optionalSlugs === []) {
220+ return [$ entries , []];
221+ }
222+
223+ $ kept = [];
224+ $ optional = [];
225+ foreach ($ entries as $ entry ) {
226+ if (in_array ($ entry ['post ' ]->getSlug (), $ optionalSlugs , true )) {
227+ $ optional [] = $ this ->buildOptionalPostLink ($ slug , $ entry );
228+ continue ;
229+ }
230+
231+ $ kept [] = $ entry ;
232+ }
233+
234+ return [$ kept , $ optional ];
235+ }
236+
177237 /**
178238 * @param list<array{post: Post, title: string, description: string}> $entries
179239 */
180240 private function buildCategorySection (string $ slug , array $ entries ): string
181241 {
182242 $ category = $ entries [0 ]['post ' ]->getCategory ();
183243 $ heading = sprintf (
184- '## %s (%d post%s) ' ,
185- $ category ->getName (),
186- count ($ entries ),
187- count ($ entries ) === 1 ? '' : 's ' ,
244+ '## %s ' ,
245+ $ category ->getName ()
188246 );
189247
190248 $ lines = [$ heading , '' ];
@@ -256,7 +314,13 @@ private function buildPagesSection(): string
256314 return '' ;
257315 }
258316
259- $ pages = [];
317+ $ pages = [
318+ [
319+ 'title ' => self ::CONTACT_TITLE ,
320+ 'path ' => self ::CONTACT_PATH ,
321+ 'description ' => self ::CONTACT_DESCRIPTION ,
322+ ],
323+ ];
260324 foreach (glob ($ this ->pagesDir . '/*.md ' ) ?: [] as $ path ) {
261325 $ slug = basename ($ path , '.md ' );
262326 if ($ slug === self ::OSS_LIFECYCLE_SLUG || in_array ($ slug , self ::DOCS_PAGES , true )) {
@@ -276,24 +340,20 @@ private function buildPagesSection(): string
276340
277341 $ pages [] = [
278342 'title ' => trim (explode ('| ' , $ title , 2 )[0 ]),
279- 'slug ' => $ slug ,
343+ 'path ' => $ slug . ' .md ' ,
280344 'description ' => $ description ,
281345 ];
282346 }
283347
284- if ($ pages === []) {
285- return '' ;
286- }
287-
288348 usort ($ pages , static fn (array $ a , array $ b ): int => strcasecmp ($ a ['title ' ], $ b ['title ' ]));
289349
290350 $ lines = ['## Pages ' , '' ];
291351 foreach ($ pages as $ page ) {
292352 $ lines [] = sprintf (
293- '- [%s](%s/%s.md ): %s ' ,
353+ '- [%s](%s/%s): %s ' ,
294354 $ page ['title ' ],
295355 $ this ->baseUrl ,
296- $ page ['slug ' ],
356+ $ page ['path ' ],
297357 $ page ['description ' ],
298358 );
299359 }
@@ -358,9 +418,6 @@ private function buildHeader(): string
358418 . 'by category and attributed to an author; URLs follow the pattern `/{category-slug}/{post-slug}/`. '
359419 . 'Each post also has a Markdown version at `/{category-slug}/{post-slug}.md`. ' ;
360420
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-
364421 return "# {$ this ->title }\n\n"
365422 . $ intro . "\n\n"
366423 . $ body . "\n\n"
0 commit comments