Repository: thephpleague/commonmark (php)

Issue:
Heading permalinks don't work if a document has the same title multiple times
<!-- Before submitting an issue, please use https://babelmark.github.io/ to compare how your Markdown input renders in other parsers. If all the CommonMark ones (including this one) show the same results then it's probably not a bug -->

**Version(s) affected**: 1.5.3

**Description**  

When using the Heading permalinks, if a document has the same title multiple times, it creates the same link, which won't work

**How to reproduce**  

```php
<?php

require "vendor/autoload.php";

use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;

// Obtain a pre-configured Environment with all the CommonMark parsers/renderers ready-to-go
$environment = Environment::createCommonMarkEnvironment();

// Add this extension
$environment->addExtension(new HeadingPermalinkExtension());

// Instantiate the converter engine and start converting some Markdown!
$converter = new CommonMarkConverter([], $environment);
echo $converter->convertToHtml("# Hello World!\n## Title\n## Title");
```

Outputs
```
<h1><a id="user-content-hello-world" href="#hello-world" name="hello-world" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Hello World!</h1>
<h2><a id="user-content-title" href="#title" name="title" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Title</h2>
<h2><a id="user-content-title" href="#title" name="title" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Title</h2>
```

The second `#title` should be `#title-1` or `#title-2`

I checked on babelmark; https://babelmark.github.io/?text=%23+title%0A%23%23+title and it's not clear what the take is on this.

I would love to use your implementation and throw away the ... thing ... I made for Daux : https://github.com/dauxio/daux.io/blob/master/libs/Format/HTML/ContentTypes/Markdown/TOC/Processor.php#L71

It's an all-in-one solution to add IDs to headers and create a Table of Contents.
The separation you made between headings and TOC seems way cleaner and I'll adopt it once it can do multiple titles as well.

P.S. Thanks for maintaining this parser, it's a really neat implementation and easy to use :)

Pull request description:
Ensure generated heading permalinks are unique
Fixes #531

Patch:
diff --git a/src/Extension/HeadingPermalink/HeadingPermalinkProcessor.php b/src/Extension/HeadingPermalink/HeadingPermalinkProcessor.php
index aaf13554..ebdb7097 100644
--- a/src/Extension/HeadingPermalink/HeadingPermalinkProcessor.php
+++ b/src/Extension/HeadingPermalink/HeadingPermalinkProcessor.php
@@ -18,8 +18,8 @@ use League\CommonMark\Extension\HeadingPermalink\Slug\SlugGeneratorInterface as
 use League\CommonMark\Inline\Element\Code;
 use League\CommonMark\Inline\Element\Text;
 use League\CommonMark\Node\Node;
-use League\CommonMark\Normalizer\SlugNormalizer;
 use League\CommonMark\Normalizer\TextNormalizerInterface;
+use League\CommonMark\Normalizer\UniqueSlugNormalizer;
 use League\CommonMark\Util\ConfigurationAwareInterface;
 use League\CommonMark\Util\ConfigurationInterface;
 
@@ -46,7 +46,7 @@ final class HeadingPermalinkProcessor implements ConfigurationAwareInterface
             @trigger_error(sprintf('Passing a %s into the %s constructor is deprecated; use a %s instead', DeprecatedSlugGeneratorInterface::class, self::class, TextNormalizerInterface::class), E_USER_DEPRECATED);
         }
 
-        $this->slugNormalizer = $slugNormalizer ?? new SlugNormalizer();
+        $this->slugNormalizer = $slugNormalizer ?? new UniqueSlugNormalizer();
     }
 
     public function setConfiguration(ConfigurationInterface $configuration)
diff --git a/src/Normalizer/UniqueSlugNormalizer.php b/src/Normalizer/UniqueSlugNormalizer.php
new file mode 100644
index 00000000..4c87f084
--- /dev/null
+++ b/src/Normalizer/UniqueSlugNormalizer.php
@@ -0,0 +1,43 @@
+<?php
+
+/*
+ * This file is part of the league/commonmark package.
+ *
+ * (c) Colin O'Dell <colinodell@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace League\CommonMark\Normalizer;
+
+final class UniqueSlugNormalizer implements TextNormalizerInterface
+{
+    /** @var SlugNormalizer */
+    private $slugNormalizer;
+
+    /** @var array<string, int> */
+    private $usages;
+
+    public function __construct()
+    {
+        $this->slugNormalizer = new SlugNormalizer();
+    }
+
+    public function normalize(string $text, $context = null): string
+    {
+        $slug = $this->slugNormalizer->normalize($text, $context);
+
+        if (!isset($this->usages[$slug])) {
+            $this->usages[$slug] = 1;
+
+            return $slug;
+        }
+
+        $count = $this->usages[$slug]++;
+
+        return $slug . '-' . $count;
+    }
+}


Test patch:
diff --git a/tests/functional/Extension/HeadingPermalink/HeadingPermalinkExtensionTest.php b/tests/functional/Extension/HeadingPermalink/HeadingPermalinkExtensionTest.php
index fa32e388..2e601dc3 100644
--- a/tests/functional/Extension/HeadingPermalink/HeadingPermalinkExtensionTest.php
+++ b/tests/functional/Extension/HeadingPermalink/HeadingPermalinkExtensionTest.php
@@ -41,6 +41,7 @@ final class HeadingPermalinkExtensionTest extends TestCase
         yield ['# Hello *World*', sprintf('<h1><a id="user-content-hello-world" href="#hello-world" name="hello-world" class="heading-permalink" aria-hidden="true" title="Permalink">%s</a>Hello <em>World</em></h1>', HeadingPermalinkRenderer::DEFAULT_SYMBOL)];
         yield ['# Hello `World`', sprintf('<h1><a id="user-content-hello-world" href="#hello-world" name="hello-world" class="heading-permalink" aria-hidden="true" title="Permalink">%s</a>Hello <code>World</code></h1>', HeadingPermalinkRenderer::DEFAULT_SYMBOL)];
         yield ["Test\n----", sprintf('<h2><a id="user-content-test" href="#test" name="test" class="heading-permalink" aria-hidden="true" title="Permalink">%s</a>Test</h2>', HeadingPermalinkRenderer::DEFAULT_SYMBOL)];
+        yield ["# Hello World!\n\n# Hello World!", sprintf("<h1><a id=\"user-content-hello-world\" href=\"#hello-world\" name=\"hello-world\" class=\"heading-permalink\" aria-hidden=\"true\" title=\"Permalink\">%s</a>Hello World!</h1>\n<h1><a id=\"user-content-hello-world-1\" href=\"#hello-world-1\" name=\"hello-world-1\" class=\"heading-permalink\" aria-hidden=\"true\" title=\"Permalink\">%s</a>Hello World!</h1>", HeadingPermalinkRenderer::DEFAULT_SYMBOL, HeadingPermalinkRenderer::DEFAULT_SYMBOL)];
     }
 
     /**
diff --git a/tests/functional/Extension/TableOfContents/data/weird-relative.html b/tests/functional/Extension/TableOfContents/data/weird-relative.html
index 90c0c6e3..4aed5c76 100644
--- a/tests/functional/Extension/TableOfContents/data/weird-relative.html
+++ b/tests/functional/Extension/TableOfContents/data/weird-relative.html
@@ -22,10 +22,10 @@
 </ul>
 </li>
 <li>
-<p><a href="#then-a-level-5">Then a level 5</a></p>
+<p><a href="#then-a-level-5-1">Then a level 5</a></p>
 <ul>
 <li>
-<p><a href="#then-a-level-6">Then a level 6</a></p>
+<p><a href="#then-a-level-6-1">Then a level 6</a></p>
 </li>
 </ul>
 </li>
@@ -35,7 +35,7 @@
 <p><a href="#and-then-a-level-3">And then a level 3</a></p>
 <ul>
 <li>
-<p><a href="#then-a-level-5">Then a level 5</a></p>
+<p><a href="#then-a-level-5-2">Then a level 5</a></p>
 </li>
 </ul>
 </li>
@@ -71,7 +71,7 @@
 <p><a href="#level-2">Level 2</a></p>
 <ul>
 <li>
-<p><a href="#level-4">Level 4</a></p>
+<p><a href="#level-4-1">Level 4</a></p>
 </li>
 </ul>
 </li>
@@ -131,10 +131,10 @@
 <h2><a id="user-content-and-another-level-2" href="#and-another-level-2" name="and-another-level-2" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>And another level 2</h2>
 <h5><a id="user-content-then-a-level-5" href="#then-a-level-5" name="then-a-level-5" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 5</h5>
 <h6><a id="user-content-then-a-level-6" href="#then-a-level-6" name="then-a-level-6" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 6</h6>
-<h5><a id="user-content-then-a-level-5" href="#then-a-level-5" name="then-a-level-5" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 5</h5>
-<h6><a id="user-content-then-a-level-6" href="#then-a-level-6" name="then-a-level-6" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 6</h6>
+<h5><a id="user-content-then-a-level-5-1" href="#then-a-level-5-1" name="then-a-level-5-1" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 5</h5>
+<h6><a id="user-content-then-a-level-6-1" href="#then-a-level-6-1" name="then-a-level-6-1" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 6</h6>
 <h3><a id="user-content-and-then-a-level-3" href="#and-then-a-level-3" name="and-then-a-level-3" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>And then a level 3</h3>
-<h5><a id="user-content-then-a-level-5" href="#then-a-level-5" name="then-a-level-5" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 5</h5>
+<h5><a id="user-content-then-a-level-5-2" href="#then-a-level-5-2" name="then-a-level-5-2" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 5</h5>
 <h2><a id="user-content-then-a-level-2" href="#then-a-level-2" name="then-a-level-2" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then a level 2</h2>
 <h6><a id="user-content-followed-by-a-level-6" href="#followed-by-a-level-6" name="followed-by-a-level-6" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Followed by a level 6</h6>
 <h3><a id="user-content-then-another-level-3" href="#then-another-level-3" name="then-another-level-3" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Then another level 3</h3>
@@ -142,7 +142,7 @@
 <h4><a id="user-content-level-4" href="#level-4" name="level-4" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Level 4</h4>
 <h3><a id="user-content-level-3" href="#level-3" name="level-3" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Level 3</h3>
 <h2><a id="user-content-level-2" href="#level-2" name="level-2" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Level 2</h2>
-<h4><a id="user-content-level-4" href="#level-4" name="level-4" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Level 4</h4>
+<h4><a id="user-content-level-4-1" href="#level-4-1" name="level-4-1" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Level 4</h4>
 <h1><a id="user-content-now-for-some-normal-headings-to-test-going-up-and-down-the-stack" href="#now-for-some-normal-headings-to-test-going-up-and-down-the-stack" name="now-for-some-normal-headings-to-test-going-up-and-down-the-stack" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Now for some &quot;normal&quot; headings to test going up and down the stack</h1>
 <h1><a id="user-content-section-1" href="#section-1" name="section-1" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Section 1</h1>
 <h2><a id="user-content-section-11" href="#section-11" name="section-11" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Section 1.1</h2>
diff --git a/tests/unit/Normalizer/UniqueSlugNormalizerTest.php b/tests/unit/Normalizer/UniqueSlugNormalizerTest.php
new file mode 100644
index 00000000..6a45ca7e
--- /dev/null
+++ b/tests/unit/Normalizer/UniqueSlugNormalizerTest.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace League\CommonMark\Tests\Unit\Normalizer;
+
+use League\CommonMark\Normalizer\UniqueSlugNormalizer;
+use PHPUnit\Framework\TestCase;
+
+final class UniqueSlugNormalizerTest extends TestCase
+{
+    public function testNormalize(): void
+    {
+        $normalizer = new UniqueSlugNormalizer();
+
+        // Providing the same input multiple times should give different slugs
+        $this->assertSame('test', $normalizer->normalize('Test'));
+        $this->assertSame('test-1', $normalizer->normalize('Test'));
+        $this->assertSame('test-2', $normalizer->normalize('Test'));
+
+        // This behavior should also work for different inputs that normally produce the same slug
+        $this->assertSame('hello-world', $normalizer->normalize('hello world'));
+        $this->assertSame('hello-world-1', $normalizer->normalize('hello-world'));
+        $this->assertSame('hello-world-2', $normalizer->normalize('Hello, World!'));
+    }
+}