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
8 changes: 4 additions & 4 deletions app/Support/CommonMark/HeadingRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]'], '#'),
)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

use App\Models\Plugin;
use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
/**
* Move heading anchor links from before heading text to after,
* and update classes for hover animation.
*/
public function up(): void
{
$oldPattern = '/(<h([1-3])\s+id="([^"]+)">)<a\s+href="#[^"]+"\s+class="mr-2\s+no-underline\s+font-medium"\s+style="border-bottom:\s*0\s*!important;">\s*<span\s+class="\s*text-gray-600\s+dark:text-gray-400\s+hover:text-\[#00aaa6\]">#<\/span><\/a>(.*?)(<\/h\2>)/s';

$newReplacement = '$1$4<a href="#$3" class="heading-anchor ml-2 no-underline font-medium" style="border-bottom: 0 !important;"><span class="text-gray-600 dark:text-gray-400 hover:text-[#00aaa6]">#</span></a>$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 = '/(<h([1-3])\s+id="([^"]+)">)(.*?)<a\s+href="#[^"]+"\s+class="heading-anchor\s+ml-2\s+no-underline\s+font-medium"\s+style="border-bottom:\s*0\s*!important;">\s*<span\s+class="text-gray-600\s+dark:text-gray-400\s+hover:text-\[#00aaa6\]">#<\/span><\/a>(<\/h\2>)/s';

$oldReplacement = '$1<a href="#$3" class="mr-2 no-underline font-medium" style="border-bottom: 0 !important;"><span class=" text-gray-600 dark:text-gray-400 hover:text-[#00aaa6]">#</span></a>$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]);
}
});
}
};
12 changes: 12 additions & 0 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
14 changes: 9 additions & 5 deletions resources/views/components/plugin-toc.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions tests/Feature/HeadingRendererTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Tests\Feature;

use App\Support\CommonMark\CommonMark;
use Tests\TestCase;

class HeadingRendererTest extends TestCase
{
public function test_heading_hash_appears_after_text(): void
{
$html = CommonMark::convertToHtml('## My Heading');

$this->assertStringContainsString('My Heading<a', $html);
$this->assertStringNotContainsString('#</span></a>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);
}
}
Loading