diff --git a/app/Support/CommonMark/CommonMark.php b/app/Support/CommonMark/CommonMark.php index 3617d709..dc943f4f 100644 --- a/app/Support/CommonMark/CommonMark.php +++ b/app/Support/CommonMark/CommonMark.php @@ -20,12 +20,18 @@ class CommonMark { protected static ?MarkdownConverter $converter = null; + protected static ?HeadingRenderer $headingRenderer = null; + public static function convertToHtml(string $markdown, array $data = []): string { // Pre-process to render any Blade components in the markdown $markdown = BladeMarkdownPreprocessor::process($markdown, $data); - return static::getConverter()->convert($markdown)->getContent(); + // Reset heading ID tracking to ensure unique IDs per conversion + static::getConverter(); + static::$headingRenderer->resetIds(); + + return static::$converter->convert($markdown)->getContent(); } protected static function getConverter(): MarkdownConverter @@ -45,7 +51,8 @@ protected static function getConverter(): MarkdownConverter $environment->addExtension(new CommonMarkCoreExtension); $environment->addExtension(new GithubFlavoredMarkdownExtension); - $environment->addRenderer(Heading::class, new HeadingRenderer); + static::$headingRenderer = new HeadingRenderer; + $environment->addRenderer(Heading::class, static::$headingRenderer); $environment->addExtension(new TableExtension); $environment->addExtension(new EmbedExtension); diff --git a/app/Support/CommonMark/HeadingRenderer.php b/app/Support/CommonMark/HeadingRenderer.php index 9756e596..909ed494 100644 --- a/app/Support/CommonMark/HeadingRenderer.php +++ b/app/Support/CommonMark/HeadingRenderer.php @@ -10,6 +10,14 @@ class HeadingRenderer implements NodeRendererInterface { + /** @var array */ + protected array $usedIds = []; + + public function resetIds(): void + { + $this->usedIds = []; + } + public function render(Node $node, ChildNodeRendererInterface $childRenderer) { $tag = 'h'.$node->getLevel(); @@ -20,6 +28,17 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer) $id = Str::slug($element->getContents()); + if ($id === '') { + $id = 'heading'; + } + + if (isset($this->usedIds[$id])) { + $this->usedIds[$id]++; + $id = $id.'-'.$this->usedIds[$id]; + } else { + $this->usedIds[$id] = 0; + } + $element->setAttribute('id', $id); if ($node->getLevel() === 1 || $node->getLevel() === 2 || $node->getLevel() === 3) { diff --git a/resources/views/components/plugin-toc.blade.php b/resources/views/components/plugin-toc.blade.php index de095caa..c156267e 100644 --- a/resources/views/components/plugin-toc.blade.php +++ b/resources/views/components/plugin-toc.blade.php @@ -5,16 +5,28 @@ const article = document.querySelector('article') if (! article) return + const seen = new Set() const elements = article.querySelectorAll('h2[id], h3[id]') - 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)), - } - }) + this.headings = Array.from(elements) + .filter(el => el.id !== '') + .map(el => { + const clone = el.cloneNode(true) + clone.querySelectorAll('.heading-anchor').forEach(a => a.remove()) + + let id = el.id + let suffix = 1 + while (seen.has(id)) { + id = el.id + '-toc-' + suffix++ + } + seen.add(id) + + return { + id: el.id, + tocKey: id, + text: clone.textContent.trim(), + level: parseInt(el.tagName.substring(1)), + } + }) }, }" x-show="headings.length > 0" @@ -28,7 +40,7 @@