Skip to content

Commit 124c2d1

Browse files
introducing
- support for fragment links (e.g. @navid:some_target_page#some_section_in_target_page) - support for @navid linkage in embedded mermaid charts
1 parent 1f70ad9 commit 124c2d1

File tree

2 files changed

+258
-8
lines changed

2 files changed

+258
-8
lines changed

src/MkDocsGenerator.php

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -700,29 +700,96 @@ private function generateStaticContent(array $node, string $pageTitle, array $re
700700
return $content;
701701
}
702702

703+
private function processMermaidReferences(string $content, array $registry, array $navPathMap, array $navIdMap, string $sourceOwner): string
704+
{
705+
// Process @navid references within Mermaid code blocks
706+
// Pattern to match Mermaid code blocks: ```mermaid ... ```
707+
return preg_replace_callback(
708+
'/^```mermaid\n((?:(?!^```)[\s\S])*?)^```/m',
709+
function ($matches) use ($registry, $navPathMap, $navIdMap, $sourceOwner) {
710+
$mermaidContent = $matches[1];
711+
712+
// Process click events with @navid references within the Mermaid content
713+
// Pattern: click element "@navid:target" "tooltip" or click element '@navid:target' 'tooltip'
714+
// Also supports: click element "@navid:target#fragment" "tooltip"
715+
// Element names can contain letters, numbers, underscores, hyphens, and dots
716+
// Tooltip is optional
717+
$clickPattern = '/click\s+([a-zA-Z0-9_.-]+)\s+(["\'])@(ref|navid):([^#"\']+)(?:#([^"\']+))?\2(?:\s+(["\'])([^"\']+)\6)?/';
718+
719+
$processedMermaidContent = preg_replace_callback(
720+
$clickPattern,
721+
function ($clickMatches) use ($registry, $navPathMap, $navIdMap, $sourceOwner) {
722+
$element = $clickMatches[1]; // The Mermaid element to click
723+
$refType = $clickMatches[3]; // 'ref' or 'navid' (adjusted index due to quote capture)
724+
$refTarget = $clickMatches[4]; // The reference target (adjusted index)
725+
$fragment = $clickMatches[5] ?? null; // Optional fragment (adjusted index)
726+
$tooltip = $clickMatches[7] ?? null; // The tooltip text (optional, adjusted index)
727+
728+
// Resolve the reference
729+
$resolvedLink = $this->resolveReference($refType, $refTarget, $registry, $navPathMap, $navIdMap, $sourceOwner);
730+
731+
if ($resolvedLink === null) {
732+
// Reference couldn't be resolved - throw build error with helpful context
733+
$sourceInfo = $sourceOwner ? " in {$sourceOwner}" : '';
734+
$fragmentInfo = $fragment ? "#{$fragment}" : '';
735+
736+
throw new \RuntimeException("Broken Mermaid reference: @{$refType}:{$refTarget}{$fragmentInfo} in Mermaid diagram{$sourceInfo}");
737+
}
738+
739+
$linkUrl = $resolvedLink['url'];
740+
741+
// Append fragment identifier if provided
742+
if ($fragment) {
743+
$linkUrl .= '#' . $fragment;
744+
}
745+
746+
// Return the processed click event with resolved URL
747+
// Include tooltip only if it was provided
748+
if ($tooltip !== null) {
749+
return "click {$element} \"{$linkUrl}\" \"{$tooltip}\"";
750+
} else {
751+
return "click {$element} \"{$linkUrl}\"";
752+
}
753+
},
754+
$mermaidContent
755+
);
756+
757+
// Return the processed Mermaid block
758+
return "```mermaid\n{$processedMermaidContent}```";
759+
},
760+
$content
761+
);
762+
}
763+
703764
private function processInlineReferences(string $content, array $registry, array $navPathMap, array $navIdMap, string $sourceOwner): string
704765
{
705-
// Process [@ref:...] and [@navid:...] syntax
766+
// First, process Mermaid code blocks to handle @navid references within them
767+
$content = $this->processMermaidReferences($content, $registry, $navPathMap, $navIdMap, $sourceOwner);
768+
769+
// Process [@ref:...] and [@navid:...] syntax with optional fragment support
706770
// Pattern explanation:
707-
// \[ - Opening bracket (always consumed)
708-
// (?:([^\]]+)\]\()? - Optional custom link text in [text]( format
709-
// @(ref|navid): - The @ref: or @navid: syntax
710-
// ([^)\]\s]+) - The reference target (no spaces, closing parens, or brackets)
711-
// [\])] - Closing bracket or paren
771+
// \[ - Opening bracket (always consumed)
772+
// (?:([^\]]+)\]\()? - Optional custom link text in [text]( format
773+
// @(ref|navid): - The @ref: or @navid: syntax
774+
// ([^#)\]\s]+) - The reference target (no #, spaces, closing parens, or brackets)
775+
// (?:#([^)\]\s]+))? - Optional fragment identifier after #
776+
// [\])] - Closing bracket or paren
712777

713-
$pattern = '/\[(?:([^\]]+)\]\()?@(ref|navid):([^)\]\s]+)[\])]/';
778+
$pattern = '/\[(?:([^\]]+)\]\()?@(ref|navid):([^#)\]\s]+)(?:#([^)\]\s]+))?[\])]/';
714779

715780
return preg_replace_callback($pattern, function ($matches) use ($registry, $navPathMap, $navIdMap, $sourceOwner) {
716781
$customText = $matches[1] !== '' ? $matches[1] : null; // Custom link text if provided
717782
$refType = $matches[2]; // 'ref' or 'navid'
718783
$refTarget = $matches[3]; // The actual reference target
784+
$fragment = $matches[4] ?? null; // Optional fragment identifier
719785

720786
// Resolve the reference based on type
721787
$resolvedLink = $this->resolveReference($refType, $refTarget, $registry, $navPathMap, $navIdMap, $sourceOwner);
722788

723789
if ($resolvedLink === null) {
724790
// Reference couldn't be resolved - throw build error with helpful context
725791
$sourceInfo = $sourceOwner ? " in {$sourceOwner}" : '';
792+
$fragmentInfo = $fragment ? "#{$fragment}" : '';
726793
$suggestion = '';
727794

728795
if ($refType === 'ref') {
@@ -737,12 +804,17 @@ private function processInlineReferences(string $content, array $registry, array
737804
"3. Or use a code block instead: `{$refTarget}`";
738805
}
739806

740-
throw new \RuntimeException("Broken reference: @{$refType}:{$refTarget}{$sourceInfo}{$suggestion}");
807+
throw new \RuntimeException("Broken reference: @{$refType}:{$refTarget}{$fragmentInfo}{$sourceInfo}{$suggestion}");
741808
}
742809

743810
$linkText = $customText ?: $resolvedLink['title'];
744811
$linkUrl = $resolvedLink['url'];
745812

813+
// Append fragment identifier if provided
814+
if ($fragment) {
815+
$linkUrl .= '#' . $fragment;
816+
}
817+
746818
return "[{$linkText}]({$linkUrl})";
747819
}, $content);
748820
}

tests/Unit/CrossReferenceProcessingTest.php

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,181 @@
305305

306306
expect(true)->toBeTrue();
307307
});
308+
309+
it('processes @navid references in Mermaid charts', function () {
310+
$documentationNodes = [
311+
[
312+
'owner' => 'App\Services\DataService',
313+
'navPath' => 'Services / Data',
314+
'navId' => 'data-service',
315+
'navParent' => null,
316+
'description' => 'Data service for processing.',
317+
'links' => [],
318+
'uses' => [],
319+
],
320+
[
321+
'owner' => 'App\Services\CacheService',
322+
'navPath' => 'Services / Cache',
323+
'navId' => 'cache-service',
324+
'navParent' => null,
325+
'description' => 'Cache service for optimization.',
326+
'links' => [],
327+
'uses' => [],
328+
],
329+
[
330+
'owner' => 'App\Services\FlowService',
331+
'navPath' => 'Services / Flow',
332+
'navId' => null,
333+
'navParent' => null,
334+
'description' => <<<'MD'
335+
## Service Flow
336+
337+
```mermaid
338+
graph LR
339+
A[Start] --> B[Process Data]
340+
B --> C[Cache Result]
341+
click B "@navid:data-service" "View Data Service"
342+
click C '@navid:cache-service' 'View Cache Service'
343+
```
344+
345+
The diagram shows the service flow.
346+
MD,
347+
'links' => [],
348+
'uses' => [],
349+
],
350+
];
351+
352+
$this->filesystem->shouldReceive('deleteDirectory')->once();
353+
$this->filesystem->shouldReceive('makeDirectory')->atLeast()->once();
354+
355+
// Capture the FlowService content to verify Mermaid references were processed
356+
$flowContent = null;
357+
$this->filesystem->shouldReceive('put')
358+
->with(Mockery::pattern('/flow\.md$/'), Mockery::on(function ($content) use (&$flowContent) {
359+
$flowContent = $content;
360+
361+
return true;
362+
}));
363+
364+
$this->filesystem->shouldReceive('put')->with(Mockery::any(), Mockery::any())->atLeast()->once();
365+
366+
$this->generator->generate($documentationNodes, '/docs');
367+
368+
// Verify Mermaid references were processed to relative URLs
369+
expect($flowContent)->toContain('```mermaid');
370+
expect($flowContent)->toContain('click B "../data/" "View Data Service"'); // Processed with tooltip
371+
expect($flowContent)->toContain('click C "../cache/" "View Cache Service"'); // Processed with tooltip (quotes normalized to double)
372+
expect($flowContent)->not->toContain('@navid:'); // References should be resolved
373+
});
374+
375+
it('supports fragment identifiers in @navid and @ref links', function () {
376+
$documentationNodes = [
377+
[
378+
'owner' => 'App\Services\AuthService',
379+
'navPath' => 'Services / Auth',
380+
'navId' => 'auth-service',
381+
'navParent' => null,
382+
'description' => <<<'MD'
383+
# Authentication Service
384+
385+
## Overview
386+
General authentication overview.
387+
388+
## Login Process
389+
Detailed login process.
390+
391+
## Security Features
392+
Security implementation details.
393+
MD,
394+
'links' => [],
395+
'uses' => [],
396+
],
397+
[
398+
'owner' => 'App\Controllers\UserController',
399+
'navPath' => 'Controllers / User',
400+
'navId' => null,
401+
'navParent' => null,
402+
'description' => <<<'MD'
403+
## User Controller
404+
405+
See the [@navid:auth-service#login-process] for authentication details.
406+
407+
Also check [@ref:App\Services\AuthService#security-features] for security info.
408+
409+
With custom text: [login details](@navid:auth-service#login-process).
410+
411+
## Mermaid with Fragments
412+
413+
```mermaid
414+
graph TD
415+
A[User Login] --> B[Auth Check]
416+
click A "@navid:auth-service#login-process" "View Login Process"
417+
click B "@ref:App\Services\AuthService#security-features"
418+
```
419+
MD,
420+
'links' => [],
421+
'uses' => [],
422+
],
423+
];
424+
425+
$this->filesystem->shouldReceive('deleteDirectory')->once();
426+
$this->filesystem->shouldReceive('makeDirectory')->atLeast()->once();
427+
428+
// Capture the UserController content to verify fragment links
429+
$userContent = null;
430+
$this->filesystem->shouldReceive('put')
431+
->with(Mockery::pattern('/user\.md$/'), Mockery::on(function ($content) use (&$userContent) {
432+
$userContent = $content;
433+
434+
return true;
435+
}));
436+
437+
$this->filesystem->shouldReceive('put')->with(Mockery::any(), Mockery::any())->atLeast()->once();
438+
439+
$this->generator->generate($documentationNodes, '/docs');
440+
441+
// Verify inline fragment links
442+
expect($userContent)->toContain('#login-process)'); // @navid with fragment
443+
expect($userContent)->toContain('#security-features)'); // @ref with fragment
444+
expect($userContent)->toContain('[login details]('); // Custom text preserved
445+
446+
// Verify Mermaid fragment links
447+
expect($userContent)->toContain('click A '); // Mermaid element preserved
448+
expect($userContent)->toContain('#login-process"'); // Fragment in Mermaid link
449+
expect($userContent)->toContain('click B '); // Mermaid element preserved
450+
expect($userContent)->toContain('#security-features"'); // Fragment in Mermaid link
451+
452+
// Ensure references are resolved
453+
expect($userContent)->not->toContain('@navid:');
454+
expect($userContent)->not->toContain('@ref:');
455+
});
456+
457+
it('throws exception for broken Mermaid references', function () {
458+
$documentationNodes = [
459+
[
460+
'owner' => 'App\Services\DiagramService',
461+
'navPath' => 'Services / Diagram',
462+
'navId' => null,
463+
'navParent' => null,
464+
'description' => <<<'MD'
465+
## Service Diagram
466+
467+
```mermaid
468+
graph LR
469+
A[Start] --> B[End]
470+
click A "@navid:nonexistent-service" "This should fail"
471+
```
472+
MD,
473+
'links' => [],
474+
'uses' => [],
475+
],
476+
];
477+
478+
$this->filesystem->shouldReceive('deleteDirectory')->atMost()->once();
479+
$this->filesystem->shouldReceive('makeDirectory')->atMost()->once();
480+
$this->filesystem->shouldReceive('put')->atMost()->once();
481+
482+
// Should throw RuntimeException for broken Mermaid reference
483+
expect(fn () => $this->generator->generate($documentationNodes, '/docs'))
484+
->toThrow(RuntimeException::class, 'Broken Mermaid reference: @navid:nonexistent-service');
485+
});

0 commit comments

Comments
 (0)