Skip to content

Commit de08863

Browse files
committed
Merge remote-tracking branch 'origin/main' into remove-heading-hash-symbol
2 parents 531f262 + 38c3eba commit de08863

File tree

7 files changed

+182
-118
lines changed

7 files changed

+182
-118
lines changed

app/Http/Controllers/ShowDocumentationController.php

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ protected function getPageProperties($platform, $version, $page = null): array
104104
$pageProperties['content'] = CommonMark::convertToHtml($document->body(), [
105105
'user' => auth()->user(),
106106
]);
107-
$pageProperties['tableOfContents'] = $this->extractTableOfContents($document->body());
107+
$pageProperties['tableOfContents'] = $this->extractTableOfContents($pageProperties['content']);
108108

109109
$navigation = $this->getNavigation($platform, $version);
110110
$pageProperties['navigation'] = Menu::build($navigation, function (Menu $menu, $nav): void {
@@ -386,21 +386,18 @@ protected function flattenNavigationPages(array $navigation): array
386386
return $pages;
387387
}
388388

389-
protected function extractTableOfContents(string $document): array
389+
protected function extractTableOfContents(string $html): array
390390
{
391-
// Remove code blocks which might contain headers.
392-
$document = preg_replace('/```[a-z]*\s(.*?)```/s', '', $document);
391+
if (! preg_match_all('/<(h[23])\s+id="([^"]+)"[^>]*>(.*?)<\/\1>/si', $html, $matches, PREG_SET_ORDER)) {
392+
return [];
393+
}
393394

394-
return collect(explode(PHP_EOL, $document))
395-
->reject(function (string $line) {
396-
// Only search for level 2 and 3 headings.
397-
return ! Str::startsWith($line, '## ') && ! Str::startsWith($line, '### ');
398-
})
399-
->map(function (string $line) {
395+
return collect($matches)
396+
->map(function (array $match) {
400397
return [
401-
'level' => strlen(trim(Str::before($line, '# '))) + 1,
402-
'title' => $title = htmlspecialchars_decode(trim(Str::after($line, '# '))),
403-
'anchor' => Str::slug(Str::replace('`', 'code', $title)),
398+
'level' => (int) $match[1][1],
399+
'title' => html_entity_decode(trim(strip_tags(preg_replace('/<a\b[^>]*>.*?<\/a>/i', '', $match[3]))), ENT_QUOTES | ENT_HTML5),
400+
'anchor' => $match[2],
404401
];
405402
})
406403
->values()

resources/css/app.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@
7979
}
8080
}
8181

82-
/* Prevents scrollbars from appearing when a popover is open */
83-
html:has(#mobile-menu-popover:popover-open) {
84-
overflow: hidden;
82+
/* Compensate for custom scrollbar width when Flux locks scroll */
83+
html[data-flux-scroll-unlock] {
84+
padding-right: 8px !important;
8585
}
8686

8787
/* Scrollbar width */

resources/views/components/docs/toc-and-sponsors.blade.php

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,6 @@
11
{{-- Copy as Markdown Button --}}
22
<x-docs.copy-markdown-button />
33

4-
{{-- On this page --}}
5-
<h3 class="flex items-center gap-1.5 text-sm opacity-60">
6-
{{-- Icon --}}
7-
<x-icons.stacked-lines class="size-[18px]" />
8-
9-
{{-- Label --}}
10-
<div>On this page</div>
11-
</h3>
12-
13-
{{-- Table of contents --}}
14-
@if (count($tableOfContents) > 0)
15-
<div
16-
class="mt-4 flex min-h-20 flex-col space-y-2 overflow-y-auto overflow-x-hidden border-l text-xs dark:border-l-white/15"
17-
>
18-
@foreach ($tableOfContents as $item)
19-
<a
20-
href="#{{ $item['anchor'] }}"
21-
@class([
22-
'transition duration-300 ease-in-out will-change-transform hover:translate-x-0.5 hover:text-violet-400 hover:opacity-100 dark:text-white/80',
23-
'pb-1 pl-3' => $item['level'] == 2,
24-
'py-1 pl-6' => $item['level'] == 3,
25-
])
26-
>
27-
{{ $item['title'] }}
28-
</a>
29-
@endforeach
30-
</div>
31-
@endif
32-
334
<div
345
class="mt-3 max-w-52 border-t border-t-black/20 pt-5 dark:border-t-white/15"
356
>

resources/views/components/plugin-toc.blade.php

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,25 @@
1515
}"
1616
x-show="headings.length > 0"
1717
x-cloak
18-
class="mb-6"
1918
>
20-
<h3 class="flex items-center gap-1.5 text-sm opacity-60">
21-
<x-icons.stacked-lines class="size-[18px]" />
22-
<div>On this page</div>
23-
</h3>
19+
<flux:dropdown position="bottom" align="end">
20+
<flux:button variant="filled" size="sm" class="!rounded-full">
21+
<x-icons.stacked-lines class="size-4" />
22+
On this page
23+
</flux:button>
2424

25-
<div class="mt-4 flex flex-col space-y-2 overflow-y-auto overflow-x-hidden border-l text-xs dark:border-l-white/15">
26-
<template x-for="heading in headings" :key="heading.id">
27-
<a
28-
:href="'#' + heading.id"
29-
:class="heading.level === 2 ? 'pb-1 pl-3' : 'py-1 pl-6'"
30-
class="transition duration-300 ease-in-out will-change-transform hover:translate-x-0.5 hover:text-violet-400 hover:opacity-100 dark:text-white/80"
31-
x-text="heading.text"
32-
></a>
33-
</template>
34-
</div>
25+
<flux:popover class="w-64">
26+
<nav class="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
27+
<template x-for="heading in headings" :key="heading.id">
28+
<a
29+
:href="'#' + heading.id"
30+
x-on:click.prevent="document.getElementById(heading.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
31+
:class="heading.level === 2 ? 'pl-2' : 'pl-5'"
32+
class="rounded-md px-2 py-1.5 text-xs transition hover:bg-zinc-100 dark:text-white/80 dark:hover:bg-zinc-700"
33+
x-text="heading.text"
34+
></a>
35+
</template>
36+
</nav>
37+
</flux:popover>
38+
</flux:dropdown>
3539
</div>

resources/views/docs/index.blade.php

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -53,37 +53,41 @@
5353
{{-- Copy as Markdown Button --}}
5454
<x-docs.copy-markdown-button />
5555

56-
<div>
57-
<h3 class="inline-flex items-center gap-1.5 text-sm opacity-50">
58-
{{-- Icon --}}
59-
<x-icons.stacked-lines class="size-[18px]" />
60-
{{-- Label --}}
61-
<div>On this page</div>
62-
</h3>
63-
@if (count($tableOfContents) > 0)
64-
<div
65-
class="mt-2 flex flex-col space-y-2 border-l text-xs dark:border-l-white/15"
66-
>
67-
@foreach ($tableOfContents as $item)
68-
<a
69-
href="#{{ $item['anchor'] }}"
70-
@class([
71-
'transition duration-300 ease-in-out will-change-transform hover:translate-x-0.5 hover:text-violet-400 hover:opacity-100 dark:text-white/80',
72-
'pb-1 pl-3' => $item['level'] == 2,
73-
'py-1 pl-6' => $item['level'] == 3,
74-
])
75-
>
76-
{{ $item['title'] }}
77-
</a>
78-
@endforeach
79-
</div>
80-
@endif
81-
</div>
82-
8356
</div>
8457

58+
@if (count($tableOfContents) > 0)
59+
<div class="sticky top-20 z-10 mt-8 mb-4 flex justify-end">
60+
<div class="rounded-full bg-white shadow-sm dark:bg-zinc-800">
61+
<flux:dropdown position="bottom" align="end">
62+
<flux:button variant="filled" size="sm" class="!rounded-full">
63+
<x-icons.stacked-lines class="size-4" />
64+
On this page
65+
</flux:button>
66+
67+
<flux:popover class="w-64">
68+
<nav class="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
69+
@foreach ($tableOfContents as $item)
70+
<a
71+
href="#{{ $item['anchor'] }}"
72+
x-on:click.prevent="document.getElementById('{{ $item['anchor'] }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
73+
@class([
74+
'rounded-md px-2 py-1.5 text-xs transition hover:bg-zinc-100 dark:text-white/80 dark:hover:bg-zinc-700',
75+
'pl-2' => $item['level'] == 2,
76+
'pl-5' => $item['level'] == 3,
77+
])
78+
>
79+
{{ $item['title'] }}
80+
</a>
81+
@endforeach
82+
</nav>
83+
</flux:popover>
84+
</flux:dropdown>
85+
</div>
86+
</div>
87+
@endif
88+
8589
<div
86-
class="prose dark:prose-invert prose-headings:scroll-mt-20 prose-headings:text-gray-800 sm:prose-headings:scroll-mt-32 dark:prose-headings:text-gray-50 mt-8 max-w-none"
90+
class="prose dark:prose-invert prose-headings:scroll-mt-20 prose-headings:text-gray-800 sm:prose-headings:scroll-mt-32 dark:prose-headings:text-gray-50 max-w-none"
8791
>
8892
{!! $content !!}
8993
</div>

resources/views/plugin-show.blade.php

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -95,38 +95,42 @@ class="font-mono text-2xl font-bold sm:text-3xl"
9595
<x-divider />
9696

9797
<div class="mt-2 flex flex-col-reverse gap-8 lg:flex-row lg:items-start">
98-
{{-- Main content - README --}}
99-
<div class="min-w-0 grow">
100-
@if ($plugin->readme_html)
101-
<x-plugin-toc />
102-
@endif
103-
104-
<article
105-
x-init="
106-
() => {
107-
motion.inView($el, () => {
108-
gsap.fromTo(
109-
$el,
110-
{ autoAlpha: 0, y: 5 },
111-
{ autoAlpha: 1, y: 0, duration: 0.7, ease: 'power1.out' },
112-
)
113-
})
114-
}
115-
"
116-
class="prose min-w-0 max-w-none grow text-gray-600 dark:text-gray-400 dark:prose-headings:text-white"
117-
aria-labelledby="plugin-title"
118-
>
98+
{{-- Main content - README --}}
99+
<div class="min-w-0 grow">
119100
@if ($plugin->readme_html)
120-
{!! $plugin->readme_html !!}
121-
@else
122-
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
123-
<p class="text-gray-500 dark:text-gray-400">
124-
README not available yet.
125-
</p>
126-
</div>
101+
<div class="sticky top-20 z-10 mb-4 flex justify-end">
102+
<div class="rounded-full bg-white shadow-sm dark:bg-zinc-800">
103+
<x-plugin-toc />
104+
</div>
105+
</div>
127106
@endif
128-
</article>
129-
</div>
107+
108+
<article
109+
x-init="
110+
() => {
111+
motion.inView($el, () => {
112+
gsap.fromTo(
113+
$el,
114+
{ autoAlpha: 0, y: 5 },
115+
{ autoAlpha: 1, y: 0, duration: 0.7, ease: 'power1.out' },
116+
)
117+
})
118+
}
119+
"
120+
class="prose min-w-0 max-w-none grow text-gray-600 prose-headings:scroll-mt-20 dark:text-gray-400 dark:prose-headings:text-white"
121+
aria-labelledby="plugin-title"
122+
>
123+
@if ($plugin->readme_html)
124+
{!! $plugin->readme_html !!}
125+
@else
126+
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
127+
<p class="text-gray-500 dark:text-gray-400">
128+
README not available yet.
129+
</p>
130+
</div>
131+
@endif
132+
</article>
133+
</div>
130134

131135
{{-- Sidebar - Plugin details --}}
132136
<aside
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Http\Controllers\ShowDocumentationController;
6+
use Tests\TestCase;
7+
8+
class DocumentationTableOfContentsTest extends TestCase
9+
{
10+
public function test_extracts_headings_from_rendered_html(): void
11+
{
12+
$html = '<h2 id="installation"><a href="#installation"><span>#</span></a>Installation</h2>'
13+
.'<p>Some text.</p>'
14+
.'<h3 id="sub-heading"><a href="#sub-heading"><span>#</span></a>Sub Heading</h3>';
15+
16+
$controller = new ShowDocumentationController;
17+
18+
$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);
19+
20+
$this->assertCount(2, $result);
21+
$this->assertEquals(['level' => 2, 'title' => 'Installation', 'anchor' => 'installation'], $result[0]);
22+
$this->assertEquals(['level' => 3, 'title' => 'Sub Heading', 'anchor' => 'sub-heading'], $result[1]);
23+
}
24+
25+
public function test_anchors_match_heading_ids_for_inline_code(): void
26+
{
27+
$html = '<h2 id="using-codeconfigcode"><a href="#using-codeconfigcode"><span>#</span></a>Using <code>config()</code></h2>';
28+
29+
$controller = new ShowDocumentationController;
30+
31+
$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);
32+
33+
$this->assertCount(1, $result);
34+
$this->assertEquals('using-codeconfigcode', $result[0]['anchor']);
35+
$this->assertEquals('Using config()', $result[0]['title']);
36+
}
37+
38+
public function test_returns_empty_array_when_no_headings(): void
39+
{
40+
$html = '<p>Just a paragraph.</p>';
41+
42+
$controller = new ShowDocumentationController;
43+
44+
$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);
45+
46+
$this->assertEmpty($result);
47+
}
48+
49+
public function test_decodes_html_entities_in_title(): void
50+
{
51+
$html = '<h2 id="installation-amp-setup"><a href="#installation-amp-setup"><span>#</span></a>Installation &amp; Setup</h2>';
52+
53+
$controller = new ShowDocumentationController;
54+
55+
$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);
56+
57+
$this->assertCount(1, $result);
58+
$this->assertEquals('Installation & Setup', $result[0]['title']);
59+
}
60+
61+
public function test_ignores_h1_and_h4_headings(): void
62+
{
63+
$html = '<h1 id="title"><a href="#title"><span>#</span></a>Title</h1>'
64+
.'<h2 id="section"><a href="#section"><span>#</span></a>Section</h2>'
65+
.'<h4 id="deep"><a href="#deep"><span>#</span></a>Deep</h4>';
66+
67+
$controller = new ShowDocumentationController;
68+
69+
$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);
70+
71+
$this->assertCount(1, $result);
72+
$this->assertEquals('section', $result[0]['anchor']);
73+
}
74+
75+
/**
76+
* @param array<mixed> $args
77+
*/
78+
protected function invokeMethod(object $object, string $method, array $args = []): mixed
79+
{
80+
$reflection = new \ReflectionMethod($object, $method);
81+
82+
return $reflection->invoke($object, ...$args);
83+
}
84+
}

0 commit comments

Comments
 (0)