Skip to content

Commit f820172

Browse files
simonhampclaude
andcommitted
Extract TOC anchors from rendered HTML, add smooth scroll and scrollbar fix
- Docs TOC now extracts heading IDs from rendered HTML instead of re-deriving slugs from raw markdown, ensuring anchors always match - Decode HTML entities in TOC titles (e.g. &amp; → &) - Add smooth scrollIntoView for TOC links on both plugin and docs pages - Add scroll-mt-20 to plugin article headings for fragment spacing - Use data-flux-scroll-unlock attribute to compensate for custom scrollbar width when Flux locks scroll Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ad29c2 commit f820172

File tree

6 files changed

+99
-23
lines changed

6 files changed

+99
-23
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: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,11 @@
7979
}
8080
}
8181

82-
/* Compensate for custom scrollbar width when overflow is hidden */
83-
/* Browser UA sets overflow:hidden on html:has(:modal); Flux JS sets it for menus */
84-
/* Neither adds padding-right to prevent layout shift with custom 8px scrollbar */
85-
html:has(dialog:modal) {
82+
/* Compensate for custom scrollbar width when Flux locks scroll */
83+
html[data-flux-scroll-unlock] {
8684
padding-right: 8px !important;
8785
}
8886

89-
html:has(#mobile-menu-popover:popover-open) {
90-
overflow: hidden;
91-
padding-right: 8px;
92-
}
93-
9487
/* Scrollbar width */
9588
::-webkit-scrollbar {
9689
height: 8px;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<template x-for="heading in headings" :key="heading.id">
2828
<a
2929
:href="'#' + heading.id"
30+
x-on:click.prevent="document.getElementById(heading.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
3031
:class="heading.level === 2 ? 'pl-2' : 'pl-5'"
3132
class="rounded-md px-2 py-1.5 text-xs transition hover:bg-zinc-100 dark:text-white/80 dark:hover:bg-zinc-700"
3233
x-text="heading.text"

resources/views/docs/index.blade.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
@foreach ($tableOfContents as $item)
7070
<a
7171
href="#{{ $item['anchor'] }}"
72+
x-on:click.prevent="document.getElementById('{{ $item['anchor'] }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
7273
@class([
7374
'rounded-md px-2 py-1.5 text-xs transition hover:bg-zinc-100 dark:text-white/80 dark:hover:bg-zinc-700',
7475
'pl-2' => $item['level'] == 2,

resources/views/plugin-show.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class="font-mono text-2xl font-bold sm:text-3xl"
117117
})
118118
}
119119
"
120-
class="prose min-w-0 max-w-none grow text-gray-600 dark:text-gray-400 dark:prose-headings:text-white"
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"
121121
aria-labelledby="plugin-title"
122122
>
123123
@if ($plugin->readme_html)
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)