diff --git a/app/Support/CommonMark/HeadingRenderer.php b/app/Support/CommonMark/HeadingRenderer.php index 62382594..9756e596 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/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 00000000..d4760603 --- /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/css/app.css b/resources/css/app.css index b87db60d..acef73c7 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 f4803e5a..de095caa 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" diff --git a/tests/Feature/HeadingRendererTest.php b/tests/Feature/HeadingRendererTest.php new file mode 100644 index 00000000..b4b29f6d --- /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); + } +}