1010final readonly class ApiMarkdownGenerator
1111{
1212 public function __construct (
13- private ApiFacadeInterface $ apiFacade
13+ private ApiFacadeInterface $ apiFacade,
1414 ) {
1515 }
1616
@@ -19,68 +19,203 @@ public function __construct(
1919 */
2020 public function generate (): array
2121 {
22- $ result = $ this ->zolaHeaders ();
23-
24- /** @var list<PhelFunction> $phelFns */
22+ $ result = $ this ->buildZolaHeaders ();
2523 $ phelFns = $ this ->apiFacade ->getPhelFunctions ();
24+ $ groupedByNamespace = $ this ->groupFunctionsByNamespace ($ phelFns );
25+
26+ $ namespaces = [];
27+ foreach ($ groupedByNamespace as $ namespace => $ functions ) {
28+ $ namespaces [] = $ this ->buildNamespaceSection ($ namespace , $ functions );
29+ }
30+
31+ return array_merge ($ result , ...$ namespaces );
32+ }
2633
27- $ groupedByNamespace = [];
34+ /**
35+ * @param list<PhelFunction> $phelFns
36+ * @return array<string, list<PhelFunction>>
37+ */
38+ private function groupFunctionsByNamespace (array $ phelFns ): array
39+ {
40+ $ grouped = [];
2841 foreach ($ phelFns as $ fn ) {
29- $ groupedByNamespace [$ fn ->namespace ][] = $ fn ;
42+ $ grouped [$ fn ->namespace ][] = $ fn ;
43+ }
44+ return $ grouped ;
45+ }
46+
47+ /**
48+ * @param list<PhelFunction> $functions
49+ * @return list<string>
50+ */
51+ private function buildNamespaceSection (string $ namespace , array $ functions ): array
52+ {
53+ $ elements = [];
54+ foreach ($ functions as $ fn ) {
55+ $ elements [] = $ this ->buildFunctionSection ($ fn );
56+ }
57+
58+ return array_merge (['' , '--- ' , '' , "## ` {$ namespace }` " , '' ], ...$ elements );
59+ }
60+
61+ /**
62+ * @return list<string>
63+ */
64+ private function buildFunctionSection (PhelFunction $ fn ): array
65+ {
66+ $ lines = ["### ` {$ fn ->nameWithNamespace ()}` " ];
67+
68+ if ($ deprecation = $ this ->buildDeprecationNotice ($ fn )) {
69+ $ lines [] = $ deprecation ;
70+ }
71+
72+ // Handle exceptional documentation blocks
73+ if ($ fn ->name === 'with-mock-wrapper ' || $ fn ->name === 'with-mocks ' ) {
74+ $ input = preg_replace ('/```phel/ ' , '```clojure ' , $ fn ->doc , 1 );
75+ $ input = preg_replace ('/^[ \t]+/m ' , '' , $ input );
76+ $ input = preg_replace ('/(?<!\n)\n(```phel)/ ' , "\n\n$1 " , $ input );
77+ } else {
78+ $ input = $ fn ->doc ;
79+ }
80+
81+ $ lines [] = $ input ;
82+
83+ if ($ example = $ this ->buildExampleSection ($ fn )) {
84+ $ lines = array_merge ($ lines , $ example );
85+ }
86+
87+ if ($ seeAlso = $ this ->buildSeeAlsoSection ($ fn )) {
88+ $ lines = array_merge ($ lines , $ seeAlso );
89+ }
90+
91+ if ($ sourceLink = $ this ->buildSourceLink ($ fn )) {
92+ $ lines [] = $ sourceLink ;
93+ }
94+ $ lines [] = '' ;
95+
96+ return $ lines ;
97+ }
98+
99+ private function buildDeprecationNotice (PhelFunction $ fn ): ?string
100+ {
101+ if (!isset ($ fn ->meta ['deprecated ' ])) {
102+ return null ;
103+ }
104+
105+ $ message = sprintf (
106+ '<small><span style="color: red; font-weight: bold;">Deprecated</span>: %s ' ,
107+ $ fn ->meta ['deprecated ' ],
108+ );
109+
110+ if (isset ($ fn ->meta ['superseded-by ' ])) {
111+ $ supersededBy = $ fn ->meta ['superseded-by ' ];
112+ $ anchor = $ this ->sanitizeAnchor ($ supersededBy );
113+ $ message .= sprintf (
114+ ' — Use [`%s`](#%s) instead ' ,
115+ $ supersededBy ,
116+ $ anchor ,
117+ );
118+ }
119+
120+ return $ message . '</small> ' ;
121+ }
122+
123+ /**
124+ * @return list<string>|null
125+ */
126+ private function buildExampleSection (PhelFunction $ fn ): ?array
127+ {
128+ if (!isset ($ fn ->meta ['example ' ])) {
129+ return null ;
30130 }
31131
32- foreach ($ groupedByNamespace as $ namespace => $ fns ) {
33-
34- $ result [] = "" ;
35- $ result [] = "--- " ;
36- $ result [] = "" ;
37- $ result [] = "## ` {$ namespace }` " ;
38-
39- /** @var PhelFunction $fn */
40- foreach ($ fns as $ fn ) {
41- $ result [] = "### ` {$ fn ->nameWithNamespace ()}` " ;
42- if (isset ($ fn ->meta ['deprecated ' ])) {
43- $ deprecatedMessage = sprintf (
44- '<small><span style="color: red; font-weight: bold;">Deprecated</span>: %s ' ,
45- $ fn ->meta ['deprecated ' ]
46- );
47- if (isset ($ fn ->meta ['superseded-by ' ])) {
48- $ supersededBy = $ fn ->meta ['superseded-by ' ];
49- $ deprecatedMessage .= sprintf (
50- ' — Use [`%s`](#%s) instead ' ,
51- $ supersededBy ,
52- $ supersededBy
53- );
54- }
55- $ deprecatedMessage .= '</small> ' ;
56- $ result [] = $ deprecatedMessage ;
57- }
58- $ result [] = $ fn ->doc ;
59- if ($ fn ->githubUrl !== '' ) {
60- $ result [] = sprintf ('<small>[[View source](%s)]</small> ' , $ fn ->githubUrl );
61- } elseif ($ fn ->docUrl !== '' ) {
62- $ result [] = sprintf ('<small>[[Read more](%s)]</small> ' , $ fn ->docUrl );
63- }
64- }
132+ return [
133+ '' ,
134+ '**Example:** ' ,
135+ '' ,
136+ '```clojure ' ,
137+ $ fn ->meta ['example ' ],
138+ '``` ' ,
139+ ];
140+ }
141+
142+ /**
143+ * @return list<string>|null
144+ */
145+ private function buildSeeAlsoSection (PhelFunction $ fn ): ?array
146+ {
147+ if (!isset ($ fn ->meta ['see-also ' ])) {
148+ return null ;
65149 }
66150
67- return $ result ;
151+ $ functionNames = $ this ->extractFunctionNames ($ fn ->meta ['see-also ' ]);
152+ $ links = $ this ->buildFunctionLinks ($ functionNames );
153+
154+ return [
155+ '' ,
156+ '**See also:** ' . implode (', ' , $ links ),
157+ ];
158+ }
159+
160+ /**
161+ * @return list<string>
162+ */
163+ private function extractFunctionNames (mixed $ seeAlso ): array
164+ {
165+ return iterator_to_array ($ seeAlso );
166+ }
167+
168+ /**
169+ * @param list<string> $functionNames
170+ * @return list<string>
171+ */
172+ private function buildFunctionLinks (array $ functionNames ): array
173+ {
174+ return array_map (
175+ fn (string $ func ) => sprintf ('[`%s`](#%s) ' , $ func , $ this ->sanitizeAnchor ($ func )),
176+ $ functionNames ,
177+ );
178+ }
179+
180+ private function buildSourceLink (PhelFunction $ fn ): ?string
181+ {
182+ if ($ fn ->githubUrl !== '' ) {
183+ return sprintf ('<small>[[View source](%s)]</small> ' , $ fn ->githubUrl );
184+ }
185+
186+ if ($ fn ->docUrl !== '' ) {
187+ return sprintf ('<small>[[Read more](%s)]</small> ' , $ fn ->docUrl );
188+ }
189+
190+ return null ;
191+ }
192+
193+ /**
194+ * Sanitize function name to match Zola's anchor generation.
195+ * Removes special characters that Zola doesn't include in anchors.
196+ *
197+ * Examples:
198+ * "empty?" becomes "empty"
199+ * "set!" becomes "set"
200+ * "php-array-to-map" stays "php-array-to-map"
201+ */
202+ private function sanitizeAnchor (string $ funcName ): string
203+ {
204+ return preg_replace ('/[^a-zA-Z0-9_-]/ ' , '' , $ funcName );
68205 }
69206
70207 /**
71208 * @return list<string>
72209 */
73- private function zolaHeaders (): array
210+ private function buildZolaHeaders (): array
74211 {
75- $ result = [];
76- $ result [] = '+++ ' ;
77- $ result [] = 'title = "API" ' ;
78- $ result [] = 'weight = 110 ' ;
79- $ result [] = 'template = "page-api.html" ' ;
80- $ result [] = 'aliases = [ "/api" ] ' ;
81- $ result [] = '+++ ' ;
82- $ result [] = '' ;
83-
84- return $ result ;
212+ return [
213+ '+++ ' ,
214+ 'title = "API" ' ,
215+ 'weight = 110 ' ,
216+ 'template = "page-api.html" ' ,
217+ 'aliases = [ "/api" ] ' ,
218+ '+++ ' ,
219+ ];
85220 }
86221}
0 commit comments