From 531f262a904ed92d2e8504166de96161498ba5ff Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 29 Mar 2026 14:34:48 +0100 Subject: [PATCH 1/2] Move heading hash symbol after text and show on hover with animation Co-Authored-By: Claude Opus 4.6 --- app/Support/CommonMark/HeadingRenderer.php | 8 +-- resources/css/app.css | 12 ++++ .../views/components/plugin-toc.blade.php | 2 +- tests/Feature/HeadingRendererTest.php | 60 +++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/HeadingRendererTest.php diff --git a/app/Support/CommonMark/HeadingRenderer.php b/app/Support/CommonMark/HeadingRenderer.php index 623825943..9756e5962 100644 --- a/app/Support/CommonMark/HeadingRenderer.php +++ b/app/Support/CommonMark/HeadingRenderer.php @@ -24,12 +24,12 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer) if ($node->getLevel() === 1 || $node->getLevel() === 2 || $node->getLevel() === 3) { $element->setContents( + $element->getContents(). new HtmlElement( 'a', - ['href' => "#{$id}", 'class' => 'mr-2 no-underline font-medium', 'style' => 'border-bottom: 0 !important;'], - new HtmlElement('span', ['class' => ' text-gray-600 dark:text-gray-400 hover:text-[#00aaa6]'], '#'), - ). - $element->getContents() + ['href' => "#{$id}", 'class' => 'heading-anchor ml-2 no-underline font-medium', 'style' => 'border-bottom: 0 !important;'], + new HtmlElement('span', ['class' => 'text-gray-600 dark:text-gray-400 hover:text-[#00aaa6]'], '#'), + ) ); } diff --git a/resources/css/app.css b/resources/css/app.css index 601e0ffaf..a82442313 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -207,6 +207,18 @@ nav.docs-navigation li:has(.third-tier .exact-active) > .subsection-header { } /* Prose */ +.prose h1, +.prose h2, +.prose h3 { + & .heading-anchor { + @apply inline-block opacity-0 translate-x-[-4px] transition-all duration-200 ease-out; + } + + &:hover .heading-anchor { + @apply opacity-100 translate-x-0; + } +} + .prose h1 { @apply text-2xl; } diff --git a/resources/views/components/plugin-toc.blade.php b/resources/views/components/plugin-toc.blade.php index ad14bdf3f..e06cfa2ba 100644 --- a/resources/views/components/plugin-toc.blade.php +++ b/resources/views/components/plugin-toc.blade.php @@ -8,7 +8,7 @@ const elements = article.querySelectorAll('h2[id], h3[id]') this.headings = Array.from(elements).map(el => ({ id: el.id, - text: el.textContent.replace(/^#\s*/, '').trim(), + text: el.textContent.replace(/\s*#$/, '').trim(), level: parseInt(el.tagName.substring(1)), })) }, diff --git a/tests/Feature/HeadingRendererTest.php b/tests/Feature/HeadingRendererTest.php new file mode 100644 index 000000000..b4b29f6dc --- /dev/null +++ b/tests/Feature/HeadingRendererTest.php @@ -0,0 +1,60 @@ +assertStringContainsString('My HeadingassertStringNotContainsString('#My Heading', $html); + } + + public function test_heading_anchor_has_correct_class(): void + { + $html = CommonMark::convertToHtml('## Test Heading'); + + $this->assertStringContainsString('heading-anchor', $html); + $this->assertStringContainsString('ml-2', $html); + } + + public function test_heading_has_id_attribute(): void + { + $html = CommonMark::convertToHtml('## My Section'); + + $this->assertStringContainsString('id="my-section"', $html); + } + + public function test_heading_anchor_links_to_id(): void + { + $html = CommonMark::convertToHtml('## My Section'); + + $this->assertStringContainsString('href="#my-section"', $html); + } + + public function test_h1_gets_anchor(): void + { + $html = CommonMark::convertToHtml('# Title'); + + $this->assertStringContainsString('heading-anchor', $html); + } + + public function test_h3_gets_anchor(): void + { + $html = CommonMark::convertToHtml('### Sub Section'); + + $this->assertStringContainsString('heading-anchor', $html); + } + + public function test_h4_does_not_get_anchor(): void + { + $html = CommonMark::convertToHtml('#### Deep Heading'); + + $this->assertStringNotContainsString('heading-anchor', $html); + } +} From 9b96e0c69067b508ffbe0992300fd72347969b8a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 29 Mar 2026 14:57:41 +0100 Subject: [PATCH 2/2] Migrate stored plugin HTML heading anchors and fix TOC text extraction Co-Authored-By: Claude Opus 4.6 --- ..._plugin_heading_anchors_in_readme_html.php | 65 +++++++++++++++++++ .../views/components/plugin-toc.blade.php | 14 ++-- 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_03_29_135109_update_plugin_heading_anchors_in_readme_html.php diff --git a/database/migrations/2026_03_29_135109_update_plugin_heading_anchors_in_readme_html.php b/database/migrations/2026_03_29_135109_update_plugin_heading_anchors_in_readme_html.php new file mode 100644 index 000000000..d47606034 --- /dev/null +++ b/database/migrations/2026_03_29_135109_update_plugin_heading_anchors_in_readme_html.php @@ -0,0 +1,65 @@ +)\s*#<\/span><\/a>(.*?)(<\/h\2>)/s'; + + $newReplacement = '$1$4#$5'; + + Plugin::query() + ->whereNotNull('readme_html') + ->each(function (Plugin $plugin) use ($oldPattern, $newReplacement) { + $updated = preg_replace($oldPattern, $newReplacement, $plugin->readme_html); + + if ($updated !== $plugin->readme_html) { + $plugin->updateQuietly(['readme_html' => $updated]); + } + }); + + Plugin::query() + ->whereNotNull('license_html') + ->each(function (Plugin $plugin) use ($oldPattern, $newReplacement) { + $updated = preg_replace($oldPattern, $newReplacement, $plugin->license_html); + + if ($updated !== $plugin->license_html) { + $plugin->updateQuietly(['license_html' => $updated]); + } + }); + } + + public function down(): void + { + $newPattern = '/()(.*?)\s*#<\/span><\/a>(<\/h\2>)/s'; + + $oldReplacement = '$1#$4$5'; + + Plugin::query() + ->whereNotNull('readme_html') + ->each(function (Plugin $plugin) use ($newPattern, $oldReplacement) { + $updated = preg_replace($newPattern, $oldReplacement, $plugin->readme_html); + + if ($updated !== $plugin->readme_html) { + $plugin->updateQuietly(['readme_html' => $updated]); + } + }); + + Plugin::query() + ->whereNotNull('license_html') + ->each(function (Plugin $plugin) use ($newPattern, $oldReplacement) { + $updated = preg_replace($newPattern, $oldReplacement, $plugin->license_html); + + if ($updated !== $plugin->license_html) { + $plugin->updateQuietly(['license_html' => $updated]); + } + }); + } +}; diff --git a/resources/views/components/plugin-toc.blade.php b/resources/views/components/plugin-toc.blade.php index 1e91a58e7..de095caa5 100644 --- a/resources/views/components/plugin-toc.blade.php +++ b/resources/views/components/plugin-toc.blade.php @@ -6,11 +6,15 @@ if (! article) return const elements = article.querySelectorAll('h2[id], h3[id]') - this.headings = Array.from(elements).map(el => ({ - id: el.id, - text: el.textContent.replace(/\s*#$/, '').trim(), - level: parseInt(el.tagName.substring(1)), - })) + this.headings = Array.from(elements).map(el => { + const clone = el.cloneNode(true) + clone.querySelectorAll('.heading-anchor').forEach(a => a.remove()) + return { + id: el.id, + text: clone.textContent.trim(), + level: parseInt(el.tagName.substring(1)), + } + }) }, }" x-show="headings.length > 0"