fix(toc): add trailing bottom spacing when the TOC scrolls

The desktop TOC is its own scroll container (overflow-y:auto + max-height),
but unlike article content — which gets trailing space via
.page-content > article::after — it had nothing below the last link. A long
TOC scrolled to the bottom left the final link flush against the edge.

Add .toc::after as a block pseudo-element inside the desktop media query,
using the same technique (and the same Firefox-clips-padding-bottom reason)
as the article. Shared component, so public (about/charte/licence) and admin
(#admin-toc) both get it. Verified with Playwright: 0px -> ~60px gap;
mobile unaffected (TOC is not a scroll container there).
This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent aa72aa5bc5
commit 626970770f
4 changed files with 212 additions and 54 deletions
+138
View File
@@ -0,0 +1,138 @@
<?php
use PHPUnit\Framework\TestCase;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
/**
* MarkdownHelperTest — TOC extraction from markdown headings.
*
* Regression: TOC labels used to show raw markdown (`*Licence*`, `**Gras**`,
* `` `code` ``), because the raw heading text was passed straight to the
* template. Labels must be plain text and hrefs must match the heading ids
* CommonMark generates on the rendered page.
*/
class MarkdownHelperTest extends TestCase
{
/**
* Render markdown exactly like the content controllers do, and return the
* heading ids in document order.
*/
private function renderedHeadingIds(string $markdown): array
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'heading_permalink' => [
'apply_id_to_heading' => true,
'id_prefix' => '',
'insert' => 'before',
'aria_hidden' => true,
],
]);
$converter->getEnvironment()->addExtension(new HeadingPermalinkExtension());
$html = $converter->convert($markdown)->getContent();
preg_match_all('/<h[1-3] id="([^"]+)"/', $html, $matches);
return $matches[1];
}
// ── Labels are plain text ─────────────────────────────────────────────────
public function testItalicMarkupIsStrippedFromLabel(): void
{
$toc = MarkdownHelper::extractToc('## *Licence* CC');
$this->assertSame('Licence CC', $toc[0]['label']);
}
public function testBoldMarkupIsStrippedFromLabel(): void
{
$toc = MarkdownHelper::extractToc('## **Gros** gras');
$this->assertSame('Gros gras', $toc[0]['label']);
}
public function testInlineCodeMarkupIsStrippedFromLabel(): void
{
$toc = MarkdownHelper::extractToc('## `code` inline');
$this->assertSame('code inline', $toc[0]['label']);
}
public function testLinkKeepsLabelTextNotUrl(): void
{
$toc = MarkdownHelper::extractToc('## [lien](http://x) ici');
$this->assertSame('lien ici', $toc[0]['label']);
}
public function testNestedMarkupIsFullyStripped(): void
{
$toc = MarkdownHelper::extractToc('## *[lien](http://x)* et `code`');
$this->assertSame('lien et code', $toc[0]['label']);
}
public function testNoMarkdownSyntaxLeaksIntoAnyLabel(): void
{
$markdown = "## *Italique*\n\n## **Gras**\n\n## `Code`\n\n## [Lien](http://x)\n";
$labels = array_column(MarkdownHelper::extractToc($markdown), 'label');
foreach ($labels as $label) {
$this->assertDoesNotMatchRegularExpression('/[*`]|\[|\]\(/', $label);
}
}
// ── Hrefs match rendered heading ids ──────────────────────────────────────
public function testHrefsMatchRenderedHeadingIdsForInlineMarkup(): void
{
$markdown = "## *Licence* CC\n\n## **Gros** gras\n\n## `code` inline\n\n## [lien](http://x) ici\n";
$toc = MarkdownHelper::extractToc($markdown);
$headingIds = $this->renderedHeadingIds($markdown);
foreach ($toc as $index => $item) {
$this->assertSame('#' . $headingIds[$index], $item['href'], "TOC href mismatch at index {$index}");
}
}
public function testHrefsMatchRenderedHeadingIdsForAccents(): void
{
$markdown = "## Été accents\n\n## Emoji 🎉 fin\n";
$toc = MarkdownHelper::extractToc($markdown);
$headingIds = $this->renderedHeadingIds($markdown);
foreach ($toc as $index => $item) {
$this->assertSame('#' . $headingIds[$index], $item['href'], "TOC href mismatch at index {$index}");
}
}
// ── Levels and structure ──────────────────────────────────────────────────
public function testLevelsAreCapturedFromHashes(): void
{
$markdown = "# Un\n\n## Deux\n\n### Trois\n";
$toc = MarkdownHelper::extractToc($markdown);
$this->assertSame([1, 2, 3], array_column($toc, 'level'));
}
public function testHeadingsDeeperThanThreeAreIgnored(): void
{
$toc = MarkdownHelper::extractToc("# Un\n\n#### Quatre\n");
$this->assertCount(1, $toc);
}
public function testContentWithoutHeadingsYieldsEmptyToc(): void
{
$this->assertSame([], MarkdownHelper::extractToc("Juste du texte.\n\nEt un paragraphe."));
}
public function testEmptyContentYieldsEmptyToc(): void
{
$this->assertSame([], MarkdownHelper::extractToc(''));
}
}