Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 186 additions & 51 deletions build/src/ApiGenerator/Application/ApiMarkdownGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
final readonly class ApiMarkdownGenerator
{
public function __construct(
private ApiFacadeInterface $apiFacade
private ApiFacadeInterface $apiFacade,
) {
}

Expand All @@ -19,68 +19,203 @@ public function __construct(
*/
public function generate(): array
{
$result = $this->zolaHeaders();

/** @var list<PhelFunction> $phelFns */
$result = $this->buildZolaHeaders();
$phelFns = $this->apiFacade->getPhelFunctions();
$groupedByNamespace = $this->groupFunctionsByNamespace($phelFns);

$namespaces = [];
foreach ($groupedByNamespace as $namespace => $functions) {
$namespaces[] = $this->buildNamespaceSection($namespace, $functions);
}

return array_merge($result, ...$namespaces);
}

$groupedByNamespace = [];
/**
* @param list<PhelFunction> $phelFns
* @return array<string, list<PhelFunction>>
*/
private function groupFunctionsByNamespace(array $phelFns): array
{
$grouped = [];
foreach ($phelFns as $fn) {
$groupedByNamespace[$fn->namespace][] = $fn;
$grouped[$fn->namespace][] = $fn;
}
return $grouped;
}

/**
* @param list<PhelFunction> $functions
* @return list<string>
*/
private function buildNamespaceSection(string $namespace, array $functions): array
{
$elements = [];
foreach ($functions as $fn) {
$elements[] = $this->buildFunctionSection($fn);
}

return array_merge(['', '---', '', "## `{$namespace}`", ''], ...$elements);
}

/**
* @return list<string>
*/
private function buildFunctionSection(PhelFunction $fn): array
{
$lines = ["### `{$fn->nameWithNamespace()}`"];

if ($deprecation = $this->buildDeprecationNotice($fn)) {
$lines[] = $deprecation;
}

// Handle exceptional documentation blocks
if ($fn->name === 'with-mock-wrapper' || $fn->name === 'with-mocks') {
$input = preg_replace('/```phel/', '```clojure', $fn->doc, 1);
$input = preg_replace('/^[ \t]+/m', '', $input);
$input = preg_replace('/(?<!\n)\n(```phel)/', "\n\n$1", $input);
} else {
$input = $fn->doc;
}

$lines[] = $input;

if ($example = $this->buildExampleSection($fn)) {
$lines = array_merge($lines, $example);
}

if ($seeAlso = $this->buildSeeAlsoSection($fn)) {
$lines = array_merge($lines, $seeAlso);
}

if ($sourceLink = $this->buildSourceLink($fn)) {
$lines[] = $sourceLink;
}
$lines[] = '';

return $lines;
}

private function buildDeprecationNotice(PhelFunction $fn): ?string
{
if (!isset($fn->meta['deprecated'])) {
return null;
}

$message = sprintf(
'<small><span style="color: red; font-weight: bold;">Deprecated</span>: %s',
$fn->meta['deprecated'],
);

if (isset($fn->meta['superseded-by'])) {
$supersededBy = $fn->meta['superseded-by'];
$anchor = $this->sanitizeAnchor($supersededBy);
$message .= sprintf(
' &mdash; Use [`%s`](#%s) instead',
$supersededBy,
$anchor,
);
}

return $message . '</small>';
}

/**
* @return list<string>|null
*/
private function buildExampleSection(PhelFunction $fn): ?array
{
if (!isset($fn->meta['example'])) {
return null;
}

foreach ($groupedByNamespace as $namespace => $fns) {

$result[] = "";
$result[] = "---";
$result[] = "";
$result[] = "## `{$namespace}`";

/** @var PhelFunction $fn */
foreach ($fns as $fn) {
$result[] = "### `{$fn->nameWithNamespace()}`";
if (isset($fn->meta['deprecated'])) {
$deprecatedMessage = sprintf(
'<small><span style="color: red; font-weight: bold;">Deprecated</span>: %s',
$fn->meta['deprecated']
);
if (isset($fn->meta['superseded-by'])) {
$supersededBy = $fn->meta['superseded-by'];
$deprecatedMessage .= sprintf(
' &mdash; Use [`%s`](#%s) instead',
$supersededBy,
$supersededBy
);
}
$deprecatedMessage .= '</small>';
$result[] = $deprecatedMessage;
}
$result[] = $fn->doc;
if ($fn->githubUrl !== '') {
$result[] = sprintf('<small>[[View source](%s)]</small>', $fn->githubUrl);
} elseif ($fn->docUrl !== '') {
$result[] = sprintf('<small>[[Read more](%s)]</small>', $fn->docUrl);
}
}
return [
'',
'**Example:**',
'',
'```clojure',
$fn->meta['example'],
'```',
];
}

/**
* @return list<string>|null
*/
private function buildSeeAlsoSection(PhelFunction $fn): ?array
{
if (!isset($fn->meta['see-also'])) {
return null;
}

return $result;
$functionNames = $this->extractFunctionNames($fn->meta['see-also']);
$links = $this->buildFunctionLinks($functionNames);

return [
'',
'**See also:** ' . implode(', ', $links),
];
}

/**
* @return list<string>
*/
private function extractFunctionNames(mixed $seeAlso): array
{
return iterator_to_array($seeAlso);
}

/**
* @param list<string> $functionNames
* @return list<string>
*/
private function buildFunctionLinks(array $functionNames): array
{
return array_map(
fn(string $func) => sprintf('[`%s`](#%s)', $func, $this->sanitizeAnchor($func)),
$functionNames,
);
}

private function buildSourceLink(PhelFunction $fn): ?string
{
if ($fn->githubUrl !== '') {
return sprintf('<small>[[View source](%s)]</small>', $fn->githubUrl);
}

if ($fn->docUrl !== '') {
return sprintf('<small>[[Read more](%s)]</small>', $fn->docUrl);
}

return null;
}

/**
* Sanitize function name to match Zola's anchor generation.
* Removes special characters that Zola doesn't include in anchors.
*
* Examples:
* "empty?" becomes "empty"
* "set!" becomes "set"
* "php-array-to-map" stays "php-array-to-map"
*/
private function sanitizeAnchor(string $funcName): string
{
return preg_replace('/[^a-zA-Z0-9_-]/', '', $funcName);
}

/**
* @return list<string>
*/
private function zolaHeaders(): array
private function buildZolaHeaders(): array
{
$result = [];
$result[] = '+++';
$result[] = 'title = "API"';
$result[] = 'weight = 110';
$result[] = 'template = "page-api.html"';
$result[] = 'aliases = [ "/api" ]';
$result[] = '+++';
$result[] = '';

return $result;
return [
'+++',
'title = "API"',
'weight = 110',
'template = "page-api.html"',
'aliases = [ "/api" ]',
'+++',
];
}
}
13 changes: 9 additions & 4 deletions build/tests/php/ApiGenerator/Domain/ApiMarkdownGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ public function test_generate_page_without_phel_functions(): void
'template = "page-api.html"',
'aliases = [ "/api" ]',
'+++',
'',
];

self::assertEquals($expected, $generator->generate());
Expand Down Expand Up @@ -53,12 +52,13 @@ public function test_generate_page_with_one_phel_function(): void
'aliases = [ "/api" ]',
'+++',
'',
'',
'---',
'',
'## `ns-1`',
'',
'### `ns-1/function-1`',
'The doc from function 1',
'',
];

self::assertEquals($expected, $generator->generate());
Expand Down Expand Up @@ -91,14 +91,16 @@ public function test_generate_page_with_multiple_phel_functions_in_same_group():
'aliases = [ "/api" ]',
'+++',
'',
'',
'---',
'',
'## `core`',
'',
'### `function-1`',
'The doc from function 1',
'',
'### `function-2`',
'The doc from function 2',
'',
];

self::assertEquals($expected, $generator->generate());
Expand Down Expand Up @@ -131,18 +133,21 @@ public function test_generate_page_with_multiple_phel_functions_in_different_gro
'aliases = [ "/api" ]',
'+++',
'',
'',
'---',
'',
'## `ns-1`',
'',
'### `ns-1/function-1`',
'The doc from function 1',
'',
'',
'---',
'',
'## `ns-2`',
'',
'### `ns-2/function-2`',
'The doc from function 2',
'',
];

self::assertEquals($expected, $generator->generate());
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"require": {
"php": ">=8.3",
"ext-json": "*",
"phel-lang/phel-lang": "^0.26",
"phel-lang/phel-lang": "dev-main",
"gacela-project/gacela": "^1.12"
},
"require-dev": {
Expand Down
Loading
Loading