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
12 changes: 11 additions & 1 deletion src/FunctionalDocBlockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
use PhpParser\Node\Stmt\Function_;
use PhpParser\NodeVisitorAbstract;

/**
* @functional
* Extracts functional documentation from PHP docblocks.
*
* @nav Main Section / Sub Section / Another Page
*
* @uses \Xentral\LaravelDocs\MkDocsGenerator
*/
class FunctionalDocBlockExtractor extends NodeVisitorAbstract
{
public array $foundDocs = [];
Expand Down Expand Up @@ -137,7 +145,9 @@ private function parseDocComment(string $docComment, string $defaultTitle, strin
}

if ($inFunctionalBlock) {
if (str_starts_with(trim((string) $testLine), '@')) {
// Check if this line contains an annotation (even if it's in a bullet list)
$trimmedTest = ltrim(trim((string) $testLine), '* -');
if (str_starts_with(trim($trimmedTest), '@')) {
break;
}
$rawFunctionalLines[] = $testLine;
Expand Down
175 changes: 152 additions & 23 deletions src/MkDocsGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
use Illuminate\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;

/**
* @functional
* Generates MkDocs documentation from extracted functional documentation.
*
* @nav Main Section / Generator / MkDocs Generator
*
* @uses \Illuminate\Filesystem\Filesystem
*/
class MkDocsGenerator
{
private array $validationWarnings = [];
Expand Down Expand Up @@ -67,7 +75,7 @@ public function generate(array $documentationNodes, string $docsBaseDir): void
$referencedBy = $this->buildReferencedByMap($processedNodes, $registry, $navPathMap, $navIdMap);

// Generate the document tree
$docTree = $this->generateDocTree($processedNodes, $registry, $navPathMap, $navIdMap, $usedBy, $referencedBy);
$docTree = $this->generateDocTree($processedNodes, $registry, $navPathMap, $navIdMap, $usedBy, $referencedBy, $processedNodes);

// Prepare output directory
$this->filesystem->deleteDirectory($docsOutputDir);
Expand Down Expand Up @@ -495,7 +503,7 @@ private function buildReferencedByMap(array $documentationNodes, array $registry
return $referencedBy;
}

private function generateDocTree(array $documentationNodes, array $registry, array $navPathMap, array $navIdMap, array $usedBy, array $referencedBy): array
private function generateDocTree(array $documentationNodes, array $registry, array $navPathMap, array $navIdMap, array $usedBy, array $referencedBy, array $allNodes): array
{
$docTree = [];
$pathRegistry = [];
Expand Down Expand Up @@ -531,7 +539,7 @@ private function generateDocTree(array $documentationNodes, array $registry, arr
}

// Generate the markdown content
$markdownContent = $this->generateMarkdownContent($node, $pageTitle, $registry, $navPathMap, $navIdMap, $usedBy, $referencedBy);
$markdownContent = $this->generateMarkdownContent($node, $pageTitle, $registry, $navPathMap, $navIdMap, $usedBy, $referencedBy, $allNodes);

// Build the path in the document tree
$docTree = $this->addToDocTree($docTree, $pathSegments, $originalPageTitle, $pageFileName, $markdownContent);
Expand Down Expand Up @@ -623,11 +631,11 @@ private function setInNestedArray(array $array, array $path, string $originalPag
return $array;
}

private function generateMarkdownContent(array $node, string $pageTitle, array $registry, array $navPathMap, array $navIdMap, array $usedBy, array $referencedBy): string
private function generateMarkdownContent(array $node, string $pageTitle, array $registry, array $navPathMap, array $navIdMap, array $usedBy, array $referencedBy, array $allNodes): string
{
// Handle static content nodes differently
if (isset($node['type']) && $node['type'] === 'static_content') {
return $this->generateStaticContent($node, $pageTitle, $registry, $navPathMap, $navIdMap, $usedBy, $referencedBy);
return $this->generateStaticContent($node, $pageTitle, $registry, $navPathMap, $navIdMap, $usedBy, $referencedBy, $allNodes);
}

$markdownContent = "# {$pageTitle}\n\n";
Expand All @@ -639,7 +647,7 @@ private function generateMarkdownContent(array $node, string $pageTitle, array $

// Add "Building Blocks Used" section
if (! empty($node['uses'])) {
$markdownContent .= $this->generateUsedComponentsSection($node, $registry, $navPathMap);
$markdownContent .= $this->generateUsedComponentsSection($node, $registry, $navPathMap, $allNodes);
}

// Add "Used By Building Blocks" section
Expand All @@ -661,7 +669,7 @@ private function generateMarkdownContent(array $node, string $pageTitle, array $
return $markdownContent;
}

private function generateStaticContent(array $node, string $pageTitle, array $registry = [], array $navPathMap = [], array $navIdMap = [], array $usedBy = [], array $referencedBy = []): string
private function generateStaticContent(array $node, string $pageTitle, array $registry = [], array $navPathMap = [], array $navIdMap = [], array $usedBy = [], array $referencedBy = [], array $allNodes = []): string
{
// For static content, we don't add the title since it might already be in the content
// We also don't add the source subtitle
Expand All @@ -677,7 +685,7 @@ private function generateStaticContent(array $node, string $pageTitle, array $re

// Add "Building Blocks Used" section if uses are defined
if (! empty($node['uses'])) {
$content .= $this->generateUsedComponentsSection($node, $registry, $navPathMap);
$content .= $this->generateUsedComponentsSection($node, $registry, $navPathMap, $allNodes);
}

// Add "Used By Building Blocks" section
Expand Down Expand Up @@ -981,39 +989,105 @@ private function generateReferencedBySection(string $ownerKey, array $referenced
return $content;
}

private function generateUsedComponentsSection(array $node, array $registry, array $navPathMap): string
/**
* Recursively collect all dependencies (transitive closure)
*
* @param string $owner The owner to collect dependencies for
* @param array $allNodes All documentation nodes
* @param array $visited Track visited nodes to detect cycles
* @param int $depth Current depth level
* @param int $maxDepth Maximum recursion depth
* @return array Array of dependencies with structure: ['owner' => string, 'depth' => int, 'uses' => array]
*/
private function collectRecursiveDependencies(string $owner, array $allNodes, array &$visited = [], int $depth = 0, int $maxDepth = 5): array
{
// Stop if max depth reached
if ($depth >= $maxDepth) {
return [];
}

// Mark as visited to detect cycles
if (isset($visited[$owner])) {
return []; // Already visited, skip to avoid cycles
}
$visited[$owner] = true;

$dependencies = [];

// Find the node for this owner
$currentNode = null;
foreach ($allNodes as $node) {
if ($node['owner'] === $owner) {
$currentNode = $node;
break;
}
}

if (! $currentNode || empty($currentNode['uses'])) {
return [];
}

// Collect direct dependencies
foreach ($currentNode['uses'] as $used) {
$usedKey = ltrim(trim((string) $used), '\\');

$dependencies[] = [
'owner' => $usedKey,
'depth' => $depth,
'uses' => [],
];

// Recursively collect dependencies of this dependency
$nestedDeps = $this->collectRecursiveDependencies($usedKey, $allNodes, $visited, $depth + 1, $maxDepth);
if (! empty($nestedDeps)) {
$dependencies[count($dependencies) - 1]['uses'] = $nestedDeps;
}
}

return $dependencies;
}

private function generateUsedComponentsSection(array $node, array $registry, array $navPathMap, array $allNodes = []): string
{
$content = "\n\n## Building Blocks Used\n\n";
$content .= "This functionality is composed of the following reusable components:\n\n";

$mermaidLinks = [];
$mermaidContent = "graph LR\n";
$mermaidContent = "graph TD\n"; // Changed to TD (top-down) for better nested visualization
$ownerId = $this->slug($node['owner']);
$ownerNavPath = $navPathMap[$node['owner']] ?? '';
$mermaidContent .= " {$ownerId}[\"{$ownerNavPath}\"];\n";

$sourcePath = $registry[$node['owner']] ?? '';

// Recursively collect all dependencies
$visited = [$node['owner'] => true]; // Mark current node as visited to prevent self-references
$allDependencies = [];

// Collect direct dependencies with recursive expansion
foreach ($node['uses'] as $used) {
$usedRaw = trim((string) $used);
$lookupKey = ltrim($usedRaw, '\\');
$usedId = $this->slug($usedRaw);
$usedNavPath = $navPathMap[$lookupKey] ?? $usedRaw;

if (isset($registry[$lookupKey])) {
$targetPath = $registry[$lookupKey];
$relativeFilePath = $this->makeRelativePath($targetPath, $sourcePath);
$relativeUrl = $this->toCleanUrl($relativeFilePath);
// Collect recursive dependencies for this component
// Reset visited for each direct dependency, but keep current node marked
$localVisited = $visited;
$nestedDeps = $this->collectRecursiveDependencies($lookupKey, $allNodes, $localVisited, 0, 5);

$content .= "* [{$usedNavPath}]({$relativeUrl})\n";
$mermaidContent .= " {$ownerId} --> {$usedId}[\"{$usedNavPath}\"];\n";
$mermaidLinks[] = "click {$usedId} \"{$relativeUrl}\" \"View documentation for {$usedRaw}\"";
} else {
$content .= "* {$usedNavPath} (Not documented)\n";
$mermaidContent .= " {$ownerId} --> {$usedId}[\"{$usedNavPath}\"];\n";
}
$allDependencies[] = [
'owner' => $lookupKey,
'depth' => 0,
'raw' => $usedRaw,
'uses' => $nestedDeps,
];
}

// Generate list content (flat list with depth indication for readability)
$this->generateDependencyList($content, $allDependencies, $registry, $navPathMap, $sourcePath, 0);

// Generate Mermaid diagram with connections
$this->addMermaidDependencies($mermaidContent, $mermaidLinks, $ownerId, $allDependencies, $registry, $navPathMap, $sourcePath);

$content .= "\n\n### Composition Graph\n\n";
$content .= "```mermaid\n";
$content .= $mermaidContent;
Expand All @@ -1026,6 +1100,61 @@ private function generateUsedComponentsSection(array $node, array $registry, arr
return $content;
}

/**
* Generate a hierarchical list of dependencies
*/
private function generateDependencyList(string &$content, array $dependencies, array $registry, array $navPathMap, string $sourcePath, int $depth = 0): void
{
$indent = str_repeat(' ', $depth);

foreach ($dependencies as $dep) {
$lookupKey = $dep['owner'];
$usedRaw = $dep['raw'] ?? $lookupKey;
$usedNavPath = $navPathMap[$lookupKey] ?? $usedRaw;

if (isset($registry[$lookupKey])) {
$targetPath = $registry[$lookupKey];
$relativeFilePath = $this->makeRelativePath($targetPath, $sourcePath);
$relativeUrl = $this->toCleanUrl($relativeFilePath);
$content .= "{$indent}* [{$usedNavPath}]({$relativeUrl})\n";
} else {
$content .= "{$indent}* {$usedNavPath} (Not documented)\n";
}

// Recursively add nested dependencies
if (! empty($dep['uses'])) {
$this->generateDependencyList($content, $dep['uses'], $registry, $navPathMap, $sourcePath, $depth + 1);
}
}
}

/**
* Recursively add dependencies to Mermaid diagram
*/
private function addMermaidDependencies(string &$mermaidContent, array &$mermaidLinks, string $parentId, array $dependencies, array $registry, array $navPathMap, string $sourcePath): void
{
foreach ($dependencies as $dep) {
$lookupKey = $dep['owner'];
$usedRaw = $dep['raw'] ?? $lookupKey;
$usedId = $this->slug($usedRaw);
$usedNavPath = $navPathMap[$lookupKey] ?? $usedRaw;

$mermaidContent .= " {$parentId} --> {$usedId}[\"{$usedNavPath}\"];\n";

if (isset($registry[$lookupKey])) {
$targetPath = $registry[$lookupKey];
$relativeFilePath = $this->makeRelativePath($targetPath, $sourcePath);
$relativeUrl = $this->toCleanUrl($relativeFilePath);
$mermaidLinks[] = "click {$usedId} \"{$relativeUrl}\" \"View documentation for {$usedRaw}\"";
}

// Recursively add nested dependencies
if (! empty($dep['uses'])) {
$this->addMermaidDependencies($mermaidContent, $mermaidLinks, $usedId, $dep['uses'], $registry, $navPathMap, $sourcePath);
}
}
}

private function generateUsedBySection(string $ownerKey, array $usedBy, array $registry, array $navPathMap): string
{
$content = "\n\n## Used By Building Blocks\n\n";
Expand Down
18 changes: 3 additions & 15 deletions workbench/config/docs.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,9 @@
'paths' => [dirname(__DIR__, 2).'/src', dirname(__DIR__).'/app'],
'output' => dirname(__DIR__, 2).'/docs',
'commands' => [
// {path} and {port} will be replaced with the configured/passed values
'build' => 'docker run --rm -v {path}:/docs squidfunk/mkdocs-material build',
'serve' => [
'docker', 'run', '--rm', '-it',
'-p', '{port}:{port}',
'-v', '{path}:/docs',
'-e', 'ADD_MODULES=mkdocs-material pymdown-extensions',
'-e', 'LIVE_RELOAD_SUPPORT=true',
'-e', 'FAST_MODE=true',
'-e', 'DOCS_DIRECTORY=/docs',
'-e', 'AUTO_UPDATE=true',
'-e', 'UPDATE_INTERVAL=1',
'-e', 'DEV_ADDR=0.0.0.0:{port}',
'polinux/mkdocs',
],
'build' => 'uvx -w mkdocs-material -w pymdown-extensions mkdocs build',
'publish' => 'uvx -w mkdocs-material -w pymdown-extensions mkdocs gh-deploy',
'serve' => 'uvx -w mkdocs-material -w pymdown-extensions mkdocs serve',
],
'config' => [
'site_name' => 'Xentral Functional Documentation',
Expand Down