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
23 changes: 10 additions & 13 deletions app/Http/Controllers/ShowDocumentationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ protected function getPageProperties($platform, $version, $page = null): array
$pageProperties['content'] = CommonMark::convertToHtml($document->body(), [
'user' => auth()->user(),
]);
$pageProperties['tableOfContents'] = $this->extractTableOfContents($document->body());
$pageProperties['tableOfContents'] = $this->extractTableOfContents($pageProperties['content']);

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

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

return collect(explode(PHP_EOL, $document))
->reject(function (string $line) {
// Only search for level 2 and 3 headings.
return ! Str::startsWith($line, '## ') && ! Str::startsWith($line, '### ');
})
->map(function (string $line) {
return collect($matches)
->map(function (array $match) {
return [
'level' => strlen(trim(Str::before($line, '# '))) + 1,
'title' => $title = htmlspecialchars_decode(trim(Str::after($line, '# '))),
'anchor' => Str::slug(Str::replace('`', 'code', $title)),
'level' => (int) $match[1][1],
'title' => html_entity_decode(trim(strip_tags(preg_replace('/<a\b[^>]*>.*?<\/a>/i', '', $match[3]))), ENT_QUOTES | ENT_HTML5),
'anchor' => $match[2],
];
})
->values()
Expand Down
6 changes: 3 additions & 3 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@
}
}

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

/* Scrollbar width */
Expand Down
29 changes: 0 additions & 29 deletions resources/views/components/docs/toc-and-sponsors.blade.php
Original file line number Diff line number Diff line change
@@ -1,35 +1,6 @@
{{-- Copy as Markdown Button --}}
<x-docs.copy-markdown-button />

{{-- On this page --}}
<h3 class="flex items-center gap-1.5 text-sm opacity-60">
{{-- Icon --}}
<x-icons.stacked-lines class="size-[18px]" />

{{-- Label --}}
<div>On this page</div>
</h3>

{{-- Table of contents --}}
@if (count($tableOfContents) > 0)
<div
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"
>
@foreach ($tableOfContents as $item)
<a
href="#{{ $item['anchor'] }}"
@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',
'pb-1 pl-3' => $item['level'] == 2,
'py-1 pl-6' => $item['level'] == 3,
])
>
{{ $item['title'] }}
</a>
@endforeach
</div>
@endif

<div
class="mt-3 max-w-52 border-t border-t-black/20 pt-5 dark:border-t-white/15"
>
Expand Down
34 changes: 19 additions & 15 deletions resources/views/components/plugin-toc.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,25 @@
}"
x-show="headings.length > 0"
x-cloak
class="mb-6"
>
<h3 class="flex items-center gap-1.5 text-sm opacity-60">
<x-icons.stacked-lines class="size-[18px]" />
<div>On this page</div>
</h3>
<flux:dropdown position="bottom" align="end">
<flux:button variant="filled" size="sm" class="!rounded-full">
<x-icons.stacked-lines class="size-4" />
On this page
</flux:button>

<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">
<template x-for="heading in headings" :key="heading.id">
<a
:href="'#' + heading.id"
:class="heading.level === 2 ? 'pb-1 pl-3' : 'py-1 pl-6'"
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"
x-text="heading.text"
></a>
</template>
</div>
<flux:popover class="w-64">
<nav class="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
<template x-for="heading in headings" :key="heading.id">
<a
:href="'#' + heading.id"
x-on:click.prevent="document.getElementById(heading.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
:class="heading.level === 2 ? 'pl-2' : 'pl-5'"
class="rounded-md px-2 py-1.5 text-xs transition hover:bg-zinc-100 dark:text-white/80 dark:hover:bg-zinc-700"
x-text="heading.text"
></a>
</template>
</nav>
</flux:popover>
</flux:dropdown>
</div>
60 changes: 32 additions & 28 deletions resources/views/docs/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,37 +53,41 @@
{{-- Copy as Markdown Button --}}
<x-docs.copy-markdown-button />

<div>
<h3 class="inline-flex items-center gap-1.5 text-sm opacity-50">
{{-- Icon --}}
<x-icons.stacked-lines class="size-[18px]" />
{{-- Label --}}
<div>On this page</div>
</h3>
@if (count($tableOfContents) > 0)
<div
class="mt-2 flex flex-col space-y-2 border-l text-xs dark:border-l-white/15"
>
@foreach ($tableOfContents as $item)
<a
href="#{{ $item['anchor'] }}"
@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',
'pb-1 pl-3' => $item['level'] == 2,
'py-1 pl-6' => $item['level'] == 3,
])
>
{{ $item['title'] }}
</a>
@endforeach
</div>
@endif
</div>

</div>

@if (count($tableOfContents) > 0)
<div class="sticky top-20 z-10 mt-8 mb-4 flex justify-end">
<div class="rounded-full bg-white shadow-sm dark:bg-zinc-800">
<flux:dropdown position="bottom" align="end">
<flux:button variant="filled" size="sm" class="!rounded-full">
<x-icons.stacked-lines class="size-4" />
On this page
</flux:button>

<flux:popover class="w-64">
<nav class="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
@foreach ($tableOfContents as $item)
<a
href="#{{ $item['anchor'] }}"
x-on:click.prevent="document.getElementById('{{ $item['anchor'] }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
@class([
'rounded-md px-2 py-1.5 text-xs transition hover:bg-zinc-100 dark:text-white/80 dark:hover:bg-zinc-700',
'pl-2' => $item['level'] == 2,
'pl-5' => $item['level'] == 3,
])
>
{{ $item['title'] }}
</a>
@endforeach
</nav>
</flux:popover>
</flux:dropdown>
</div>
</div>
@endif

<div
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"
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"
>
{!! $content !!}
</div>
Expand Down
64 changes: 34 additions & 30 deletions resources/views/plugin-show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,38 +95,42 @@ class="font-mono text-2xl font-bold sm:text-3xl"
<x-divider />

<div class="mt-2 flex flex-col-reverse gap-8 lg:flex-row lg:items-start">
{{-- Main content - README --}}
<div class="min-w-0 grow">
@if ($plugin->readme_html)
<x-plugin-toc />
@endif

<article
x-init="
() => {
motion.inView($el, () => {
gsap.fromTo(
$el,
{ autoAlpha: 0, y: 5 },
{ autoAlpha: 1, y: 0, duration: 0.7, ease: 'power1.out' },
)
})
}
"
class="prose min-w-0 max-w-none grow text-gray-600 dark:text-gray-400 dark:prose-headings:text-white"
aria-labelledby="plugin-title"
>
{{-- Main content - README --}}
<div class="min-w-0 grow">
@if ($plugin->readme_html)
{!! $plugin->readme_html !!}
@else
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
<p class="text-gray-500 dark:text-gray-400">
README not available yet.
</p>
</div>
<div class="sticky top-20 z-10 mb-4 flex justify-end">
<div class="rounded-full bg-white shadow-sm dark:bg-zinc-800">
<x-plugin-toc />
</div>
</div>
@endif
</article>
</div>

<article
x-init="
() => {
motion.inView($el, () => {
gsap.fromTo(
$el,
{ autoAlpha: 0, y: 5 },
{ autoAlpha: 1, y: 0, duration: 0.7, ease: 'power1.out' },
)
})
}
"
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"
aria-labelledby="plugin-title"
>
@if ($plugin->readme_html)
{!! $plugin->readme_html !!}
@else
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
<p class="text-gray-500 dark:text-gray-400">
README not available yet.
</p>
</div>
@endif
</article>
</div>

{{-- Sidebar - Plugin details --}}
<aside
Expand Down
84 changes: 84 additions & 0 deletions tests/Feature/DocumentationTableOfContentsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Tests\Feature;

use App\Http\Controllers\ShowDocumentationController;
use Tests\TestCase;

class DocumentationTableOfContentsTest extends TestCase
{
public function test_extracts_headings_from_rendered_html(): void
{
$html = '<h2 id="installation"><a href="#installation"><span>#</span></a>Installation</h2>'
.'<p>Some text.</p>'
.'<h3 id="sub-heading"><a href="#sub-heading"><span>#</span></a>Sub Heading</h3>';

$controller = new ShowDocumentationController;

$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);

$this->assertCount(2, $result);
$this->assertEquals(['level' => 2, 'title' => 'Installation', 'anchor' => 'installation'], $result[0]);
$this->assertEquals(['level' => 3, 'title' => 'Sub Heading', 'anchor' => 'sub-heading'], $result[1]);
}

public function test_anchors_match_heading_ids_for_inline_code(): void
{
$html = '<h2 id="using-codeconfigcode"><a href="#using-codeconfigcode"><span>#</span></a>Using <code>config()</code></h2>';

$controller = new ShowDocumentationController;

$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);

$this->assertCount(1, $result);
$this->assertEquals('using-codeconfigcode', $result[0]['anchor']);
$this->assertEquals('Using config()', $result[0]['title']);
}

public function test_returns_empty_array_when_no_headings(): void
{
$html = '<p>Just a paragraph.</p>';

$controller = new ShowDocumentationController;

$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);

$this->assertEmpty($result);
}

public function test_decodes_html_entities_in_title(): void
{
$html = '<h2 id="installation-amp-setup"><a href="#installation-amp-setup"><span>#</span></a>Installation &amp; Setup</h2>';

$controller = new ShowDocumentationController;

$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);

$this->assertCount(1, $result);
$this->assertEquals('Installation & Setup', $result[0]['title']);
}

public function test_ignores_h1_and_h4_headings(): void
{
$html = '<h1 id="title"><a href="#title"><span>#</span></a>Title</h1>'
.'<h2 id="section"><a href="#section"><span>#</span></a>Section</h2>'
.'<h4 id="deep"><a href="#deep"><span>#</span></a>Deep</h4>';

$controller = new ShowDocumentationController;

$result = $this->invokeMethod($controller, 'extractTableOfContents', [$html]);

$this->assertCount(1, $result);
$this->assertEquals('section', $result[0]['anchor']);
}

/**
* @param array<mixed> $args
*/
protected function invokeMethod(object $object, string $method, array $args = []): mixed
{
$reflection = new \ReflectionMethod($object, $method);

return $reflection->invoke($object, ...$args);
}
}
Loading