Skip to content

Commit 49d0c12

Browse files
simonhampclaude
andauthored
Move heading # symbol after text, show on hover with animation (#308)
* Move heading hash symbol after text and show on hover with animation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Migrate stored plugin HTML heading anchors and fix TOC text extraction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 38c3eba commit 49d0c12

File tree

5 files changed

+150
-9
lines changed

5 files changed

+150
-9
lines changed

app/Support/CommonMark/HeadingRenderer.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer)
2424

2525
if ($node->getLevel() === 1 || $node->getLevel() === 2 || $node->getLevel() === 3) {
2626
$element->setContents(
27+
$element->getContents().
2728
new HtmlElement(
2829
'a',
29-
['href' => "#{$id}", 'class' => 'mr-2 no-underline font-medium', 'style' => 'border-bottom: 0 !important;'],
30-
new HtmlElement('span', ['class' => ' text-gray-600 dark:text-gray-400 hover:text-[#00aaa6]'], '#'),
31-
).
32-
$element->getContents()
30+
['href' => "#{$id}", 'class' => 'heading-anchor ml-2 no-underline font-medium', 'style' => 'border-bottom: 0 !important;'],
31+
new HtmlElement('span', ['class' => 'text-gray-600 dark:text-gray-400 hover:text-[#00aaa6]'], '#'),
32+
)
3333
);
3434
}
3535

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
use App\Models\Plugin;
4+
use Illuminate\Database\Migrations\Migration;
5+
6+
return new class extends Migration
7+
{
8+
/**
9+
* Move heading anchor links from before heading text to after,
10+
* and update classes for hover animation.
11+
*/
12+
public function up(): void
13+
{
14+
$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';
15+
16+
$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';
17+
18+
Plugin::query()
19+
->whereNotNull('readme_html')
20+
->each(function (Plugin $plugin) use ($oldPattern, $newReplacement) {
21+
$updated = preg_replace($oldPattern, $newReplacement, $plugin->readme_html);
22+
23+
if ($updated !== $plugin->readme_html) {
24+
$plugin->updateQuietly(['readme_html' => $updated]);
25+
}
26+
});
27+
28+
Plugin::query()
29+
->whereNotNull('license_html')
30+
->each(function (Plugin $plugin) use ($oldPattern, $newReplacement) {
31+
$updated = preg_replace($oldPattern, $newReplacement, $plugin->license_html);
32+
33+
if ($updated !== $plugin->license_html) {
34+
$plugin->updateQuietly(['license_html' => $updated]);
35+
}
36+
});
37+
}
38+
39+
public function down(): void
40+
{
41+
$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';
42+
43+
$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';
44+
45+
Plugin::query()
46+
->whereNotNull('readme_html')
47+
->each(function (Plugin $plugin) use ($newPattern, $oldReplacement) {
48+
$updated = preg_replace($newPattern, $oldReplacement, $plugin->readme_html);
49+
50+
if ($updated !== $plugin->readme_html) {
51+
$plugin->updateQuietly(['readme_html' => $updated]);
52+
}
53+
});
54+
55+
Plugin::query()
56+
->whereNotNull('license_html')
57+
->each(function (Plugin $plugin) use ($newPattern, $oldReplacement) {
58+
$updated = preg_replace($newPattern, $oldReplacement, $plugin->license_html);
59+
60+
if ($updated !== $plugin->license_html) {
61+
$plugin->updateQuietly(['license_html' => $updated]);
62+
}
63+
});
64+
}
65+
};

resources/css/app.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,18 @@ nav.docs-navigation li:has(.third-tier .exact-active) > .subsection-header {
207207
}
208208

209209
/* Prose */
210+
.prose h1,
211+
.prose h2,
212+
.prose h3 {
213+
& .heading-anchor {
214+
@apply inline-block opacity-0 translate-x-[-4px] transition-all duration-200 ease-out;
215+
}
216+
217+
&:hover .heading-anchor {
218+
@apply opacity-100 translate-x-0;
219+
}
220+
}
221+
210222
.prose h1 {
211223
@apply text-2xl;
212224
}

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
if (! article) return
77
88
const elements = article.querySelectorAll('h2[id], h3[id]')
9-
this.headings = Array.from(elements).map(el => ({
10-
id: el.id,
11-
text: el.textContent.replace(/^#\s*/, '').trim(),
12-
level: parseInt(el.tagName.substring(1)),
13-
}))
9+
this.headings = Array.from(elements).map(el => {
10+
const clone = el.cloneNode(true)
11+
clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
12+
return {
13+
id: el.id,
14+
text: clone.textContent.trim(),
15+
level: parseInt(el.tagName.substring(1)),
16+
}
17+
})
1418
},
1519
}"
1620
x-show="headings.length > 0"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Support\CommonMark\CommonMark;
6+
use Tests\TestCase;
7+
8+
class HeadingRendererTest extends TestCase
9+
{
10+
public function test_heading_hash_appears_after_text(): void
11+
{
12+
$html = CommonMark::convertToHtml('## My Heading');
13+
14+
$this->assertStringContainsString('My Heading<a', $html);
15+
$this->assertStringNotContainsString('#</span></a>My Heading', $html);
16+
}
17+
18+
public function test_heading_anchor_has_correct_class(): void
19+
{
20+
$html = CommonMark::convertToHtml('## Test Heading');
21+
22+
$this->assertStringContainsString('heading-anchor', $html);
23+
$this->assertStringContainsString('ml-2', $html);
24+
}
25+
26+
public function test_heading_has_id_attribute(): void
27+
{
28+
$html = CommonMark::convertToHtml('## My Section');
29+
30+
$this->assertStringContainsString('id="my-section"', $html);
31+
}
32+
33+
public function test_heading_anchor_links_to_id(): void
34+
{
35+
$html = CommonMark::convertToHtml('## My Section');
36+
37+
$this->assertStringContainsString('href="#my-section"', $html);
38+
}
39+
40+
public function test_h1_gets_anchor(): void
41+
{
42+
$html = CommonMark::convertToHtml('# Title');
43+
44+
$this->assertStringContainsString('heading-anchor', $html);
45+
}
46+
47+
public function test_h3_gets_anchor(): void
48+
{
49+
$html = CommonMark::convertToHtml('### Sub Section');
50+
51+
$this->assertStringContainsString('heading-anchor', $html);
52+
}
53+
54+
public function test_h4_does_not_get_anchor(): void
55+
{
56+
$html = CommonMark::convertToHtml('#### Deep Heading');
57+
58+
$this->assertStringNotContainsString('heading-anchor', $html);
59+
}
60+
}

0 commit comments

Comments
 (0)