Skip to content

Commit bce7561

Browse files
feat: resolve relative markdown links to internal docs routes (#31)
* feat: resolve relative markdown links to internal docs routes Convert relative .md links (e.g. installation.md, ../README.md) found in documentation files to internal /docs/{page} URLs, falling back to GitHub URLs only for external or unresolvable links. * fix: rename INFO alert type to NOTE in GitHub alert parser * chore: update IDE helpers and lock file
1 parent 9665b58 commit bce7561

8 files changed

Lines changed: 223 additions & 77 deletions

File tree

.phpstorm.meta.php

Lines changed: 81 additions & 57 deletions
Large diffs are not rendered by default.

_ide_helper.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24282,6 +24282,10 @@ public static function response($callback)
2428224282
*/
2428324283
class DocsViewer extends \Livewire\Component {
2428424284
}
24285+
/**
24286+
*/
24287+
class CommandIndex extends \Livewire\Component {
24288+
}
2428524289
}
2428624290

2428724291

app/Services/DocumentService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ private function loadFromPath(string $filePath, string $documentIdentity, string
8181
return null;
8282
}
8383

84-
$html = $this->markdown->toHtml($content);
84+
$html = $this->markdown->toHtml($content, $documentIdentity === 'readme' ? null : $titleFallback, $filePath);
8585
$headings = $this->headingExtractor->extract($html);
8686
$title = $this->extractTitle($html, $titleFallback);
8787

app/Services/MarkdownService.php

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,18 @@ public function __construct()
4545
/**
4646
* Convert markdown to HTML with GitHub-style alert boxes, code block wrappers, and link transformation.
4747
*/
48-
public function toHtml(string $markdown, ?string $currentSection = null): string
49-
{
48+
public function toHtml(
49+
string $markdown,
50+
?string $currentSection = null,
51+
?string $sourceFilePath = null,
52+
): string {
5053
$markdown = $this->stripMarkdownArtifacts($markdown);
5154
$html = $this->converter->convert($markdown)->getContent();
5255
$html = $this->stripContentBeforeH1($html);
5356
$html = $this->convertGitHubAlerts($html);
5457
$html = $this->wrapCodeBlocks($html);
5558

56-
return $this->transformLinks($html, $currentSection);
59+
return $this->transformLinks($html, $currentSection, $sourceFilePath);
5760
}
5861

5962
/**
@@ -104,7 +107,7 @@ private function stripMarkdownArtifacts(string $markdown): string
104107
* Convert GitHub-style alerts to styled callout boxes.
105108
*
106109
* Transforms blockquotes like:
107-
* > [!INFO]
110+
* > [!NOTE]
108111
* > Content here
109112
*
110113
* Into styled alert boxes with icons using Blade component.
@@ -117,7 +120,7 @@ private function convertGitHubAlerts(string $html): string
117120
// ...optional additional block elements (e.g. <p>, <ul>, <pre>)...
118121
// </blockquote>
119122
return preg_replace_callback(
120-
'/<blockquote>\s*<p>\s*\[!(INFO|IMPORTANT)\]\s*(.*?)<\/p>(.*?)<\/blockquote>/is',
123+
'/<blockquote>\s*<p>\s*\[!(NOTE|IMPORTANT)\]\s*(.*?)<\/p>(.*?)<\/blockquote>/is',
121124
function (array $matches): string {
122125
$type = strtolower(trim($matches[1]));
123126
$firstParagraph = trim($matches[2]);
@@ -178,11 +181,11 @@ function (array $matches): string {
178181
* Converts links like `section/file.md` or `/docs/section/page` to full
179182
* GitHub URLs pointing to the source repository.
180183
*/
181-
private function transformLinks(string $html, ?string $currentSection): string
184+
private function transformLinks(string $html, ?string $currentSection, ?string $sourceFilePath): string
182185
{
183186
return preg_replace_callback(
184187
'/<a\s+href="([^"]+)"([^>]*)>/i',
185-
fn (array $matches): string => $this->transformLink($matches, $currentSection),
188+
fn (array $matches): string => $this->transformLink($matches, $currentSection, $sourceFilePath),
186189
$html
187190
) ?? $html;
188191
}
@@ -195,7 +198,7 @@ private function transformLinks(string $html, ?string $currentSection): string
195198
*
196199
* @param array<int, string> $matches
197200
*/
198-
private function transformLink(array $matches, ?string $currentSection): string
201+
private function transformLink(array $matches, ?string $currentSection, ?string $sourceFilePath): string
199202
{
200203
$href = $matches[1];
201204
$attributes = $matches[2];
@@ -212,6 +215,12 @@ private function transformLink(array $matches, ?string $currentSection): string
212215
return sprintf('<a href="%s"%s>', $internalUrl, $attributes);
213216
}
214217

218+
$relativeDocsUrl = $this->resolveRelativeDocsUrl($href, $sourceFilePath);
219+
220+
if ($relativeDocsUrl !== null) {
221+
return sprintf('<a href="%s"%s>', $relativeDocsUrl, $attributes);
222+
}
223+
215224
// All other links → GitHub with target="_blank"
216225
$githubUrl = $this->resolveGitHubUrl($href, $currentSection);
217226

@@ -222,6 +231,84 @@ private function transformLink(array $matches, ?string $currentSection): string
222231
);
223232
}
224233

234+
private function resolveRelativeDocsUrl(string $href, ?string $sourceFilePath): ?string
235+
{
236+
if ($sourceFilePath === null || str_starts_with($href, '/')) {
237+
return null;
238+
}
239+
240+
[$path, $fragment] = $this->splitFragment($href);
241+
242+
if ($path === '' || preg_match('/^[a-z][a-z0-9+.-]*:/i', $path) === 1) {
243+
return null;
244+
}
245+
246+
$docsDirectory = config('docs.path');
247+
248+
if (! is_string($docsDirectory) || $docsDirectory === '') {
249+
return null;
250+
}
251+
252+
$absoluteDocsDirectory = str_starts_with($docsDirectory, '/')
253+
? $docsDirectory
254+
: base_path($docsDirectory);
255+
256+
$resolvedDocsDirectory = realpath($absoluteDocsDirectory);
257+
$resolvedSourcePath = realpath($sourceFilePath);
258+
259+
if ($resolvedDocsDirectory === false || $resolvedSourcePath === false) {
260+
return null;
261+
}
262+
263+
$sourceDirectory = dirname($resolvedSourcePath);
264+
$candidatePath = $path;
265+
266+
if (! str_ends_with($candidatePath, '.md')) {
267+
$candidatePath .= '.md';
268+
}
269+
270+
$resolvedTargetPath = realpath($sourceDirectory.'/'.$candidatePath);
271+
272+
if ($resolvedTargetPath === false || ! str_ends_with($resolvedTargetPath, '.md')) {
273+
return null;
274+
}
275+
276+
$resolvedDocsParentReadme = realpath(dirname($resolvedDocsDirectory).'/README.md');
277+
278+
if ($resolvedDocsParentReadme !== false && $resolvedTargetPath === $resolvedDocsParentReadme) {
279+
return '/'.$fragment;
280+
}
281+
282+
$docsPrefix = rtrim($resolvedDocsDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
283+
284+
if (! str_starts_with($resolvedTargetPath, $docsPrefix)) {
285+
return null;
286+
}
287+
288+
$relativeTargetPath = substr($resolvedTargetPath, strlen($docsPrefix));
289+
$page = preg_replace('/\.md$/', '', $relativeTargetPath);
290+
291+
if ($page === null || $page === '' || preg_match('/^[a-z0-9-]+$/', $page) !== 1) {
292+
return null;
293+
}
294+
295+
return "/docs/{$page}{$fragment}";
296+
}
297+
298+
/**
299+
* @return array{0: string, 1: string}
300+
*/
301+
private function splitFragment(string $href): array
302+
{
303+
if (! str_contains($href, '#')) {
304+
return [$href, ''];
305+
}
306+
307+
[$path, $fragment] = explode('#', $href, 2);
308+
309+
return [$path, '#'.$fragment];
310+
}
311+
225312
/**
226313
* Determine if a link points to a docs page by path prefix.
227314
*/

composer.lock

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/Feature/DocsSidebarNavigationTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@
3939
->assertSeeText('Requirements');
4040
});
4141

42+
it('converts relative markdown docs links to internal routes', function (): void {
43+
app()->forgetInstance(DocsPathService::class);
44+
app()->forgetInstance(TocParserService::class);
45+
app()->forgetInstance(DocumentService::class);
46+
47+
$response = $this->get(route('docs.show', ['page' => 'documentation']));
48+
49+
$response->assertOk()
50+
->assertSee('href="'.route('home').'"', false)
51+
->assertSee('href="'.route('docs.show', ['page' => 'installation']).'"', false)
52+
->assertSee('href="'.route('docs.show', ['page' => 'link-behavior']).'"', false)
53+
->assertSee('href="https://github.com/loadinglucian/deployer-php/blob/main/docs/operations/runbooks.md" target="_blank" rel="noopener noreferrer"', false);
54+
});
55+
4256
it('redirects missing docs pages to the docs home', function (): void {
4357
app()->forgetInstance(DocsPathService::class);
4458
app()->forgetInstance(TocParserService::class);

tests/Fixtures/docs/docs/documentation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<a name="guides"></a>
22
## Guides
3-
- [Introduction](README.md)
3+
- [Introduction](../README.md)
44
- [Installation](installation.md)
55
- [Link Behavior](link-behavior.md)
66

tests/Unit/MarkdownServiceTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@
107107
->toContain('<li>Second item</li>');
108108
});
109109

110+
it('renders note alerts with block content', function (): void {
111+
$markdown = <<<'MARKDOWN'
112+
> [!NOTE]
113+
> First note callout.
114+
>
115+
> - First item
116+
> - Second item
117+
MARKDOWN;
118+
119+
$html = app(MarkdownService::class)->toHtml($markdown);
120+
121+
expect(substr_count($html, 'dark:border-l-cyan-400'))->toBe(1)
122+
->and($html)
123+
->toContain('<li>First item</li>')
124+
->toContain('<li>Second item</li>');
125+
});
126+
110127
it('keeps empty github alert markers unchanged when there is no content', function (): void {
111128
$markdown = <<<'MARKDOWN'
112129
> [!IMPORTANT]

0 commit comments

Comments
 (0)