diff --git a/packages/framework/src/Framework/Actions/GeneratesTableOfContents.php b/packages/framework/src/Framework/Actions/GeneratesTableOfContents.php index ae27d6af08c..7f217ed8861 100644 --- a/packages/framework/src/Framework/Actions/GeneratesTableOfContents.php +++ b/packages/framework/src/Framework/Actions/GeneratesTableOfContents.php @@ -6,7 +6,7 @@ use Hyde\Facades\Config; use Hyde\Markdown\Models\Markdown; -use Illuminate\Support\Str; +use Hyde\Markdown\Processing\HeadingRenderer; /** * Generates a nested table of contents from Markdown headings. @@ -117,7 +117,7 @@ protected function createHeadingEntry(array $headingData): array return [ 'level' => $headingData['level'], 'title' => $headingData['title'], - 'slug' => Str::slug($headingData['title']), + 'slug' => HeadingRenderer::makeIdentifier($headingData['title']), ]; } diff --git a/packages/framework/src/Markdown/Processing/HeadingRenderer.php b/packages/framework/src/Markdown/Processing/HeadingRenderer.php index 397936b16b5..dcb4517c3f9 100644 --- a/packages/framework/src/Markdown/Processing/HeadingRenderer.php +++ b/packages/framework/src/Markdown/Processing/HeadingRenderer.php @@ -71,7 +71,7 @@ public function postProcess(string $html): string protected function makeHeadingId(string $contents): string { - $identifier = $this->ensureIdentifierIsUnique(Str::slug($contents)); + $identifier = $this->ensureIdentifierIsUnique(static::makeIdentifier($contents)); $this->headingRegistry[] = $identifier; @@ -89,4 +89,10 @@ protected function ensureIdentifierIsUnique(string $slug): string return $identifier; } + + /** @internal */ + public static function makeIdentifier(string $title): string + { + return e(Str::slug(Str::transliterate(html_entity_decode($title)), dictionary: ['@' => 'at', '&' => 'and', '<' => '', '>' => ''])); + } } diff --git a/packages/framework/tests/Feature/MarkdownHeadingRendererTest.php b/packages/framework/tests/Feature/MarkdownHeadingRendererTest.php index f69d07a9e2a..a254be2949d 100644 --- a/packages/framework/tests/Feature/MarkdownHeadingRendererTest.php +++ b/packages/framework/tests/Feature/MarkdownHeadingRendererTest.php @@ -183,9 +183,8 @@ public function testHeadingsWithSpecialCharacters() $this->assertStringContainsString('Heading with & special < > "characters"', $html); $this->assertStringContainsString('Heading with émojis 🎉', $html); - // Todo: Try to normalize to heading-with-special-characters? $this->assertSame(<<<'HTML' -

Heading with & special < > "characters"#

+

Heading with & special < > "characters"#

Heading with émojis 🎉#

HTML, $html); diff --git a/packages/framework/tests/Unit/HeadingRendererUnitTest.php b/packages/framework/tests/Unit/HeadingRendererUnitTest.php index 63a7bdc9aae..2b71abb4677 100644 --- a/packages/framework/tests/Unit/HeadingRendererUnitTest.php +++ b/packages/framework/tests/Unit/HeadingRendererUnitTest.php @@ -25,6 +25,7 @@ class HeadingRendererUnitTest extends UnitTestCase use UsesRealBladeInUnitTests; protected static bool $needsConfig = true; + protected static bool $needsKernel = true; protected static ?array $cachedConfig = null; protected function setUp(): void @@ -233,6 +234,23 @@ public function testPostProcessHandlesNoHeadingTags() $this->assertSame('

Paragraph

', (new HeadingRenderer())->postProcess($html)); } + /** + * @dataProvider headingIdentifierProvider + */ + public function testHeadingIdentifierGeneration($input, $expected) + { + $this->assertSame($expected, HeadingRenderer::makeIdentifier($input)); + } + + /** + * @dataProvider headingIdentifierProvider + */ + public function testHeadingIdentifierGenerationWithEscapedInput($input, $expected) + { + $this->assertSame(HeadingRenderer::makeIdentifier($input), HeadingRenderer::makeIdentifier(e($input))); + $this->assertSame($expected, HeadingRenderer::makeIdentifier(e($input))); + } + protected function mockChildNodeRenderer(string $contents = 'Test Heading'): ChildNodeRendererInterface { $childRenderer = Mockery::mock(ChildNodeRendererInterface::class); @@ -255,4 +273,44 @@ protected function validateHeadingPermalinkStates(HeadingRenderer $renderer, Chi } } } + + public static function headingIdentifierProvider(): array + { + return [ + // Basic cases + ['Hello World', 'hello-world'], + ['Simple Heading', 'simple-heading'], + ['Heading With Numbers 123', 'heading-with-numbers-123'], + + // Special characters + ['Heading with & symbol', 'heading-with-and-symbol'], + ['Heading with < > symbols', 'heading-with-symbols'], + ['Heading with "quotes"', 'heading-with-quotes'], + ['Heading with / and \\', 'heading-with-and'], + ['Heading with punctuation!?!', 'heading-with-punctuation'], + ['Hyphenated-heading-name', 'hyphenated-heading-name'], + + // Emojis + ['Heading with emoji 🎉', 'heading-with-emoji'], + ['Another emoji 🤔 test', 'another-emoji-test'], + ['Multiple emojis 🎉🤔✨', 'multiple-emojis'], + + // Accented and non-ASCII characters + ['Accented é character', 'accented-e-character'], + ['Café Crème', 'cafe-creme'], + ['Łódź and święto', 'lodz-and-swieto'], + ['中文标题', 'zhong-wen-biao-ti'], + ['日本語の見出し', 'ri-ben-yu-nojian-chu-shi'], + ['한국어 제목', 'hangugeo-jemog'], + + // Edge cases + [' Leading spaces', 'leading-spaces'], + ['Trailing spaces ', 'trailing-spaces'], + [' Surrounded by spaces ', 'surrounded-by-spaces'], + ['----', ''], + ['%%%%%%%', ''], + [' ', ''], + ['1234567890', '1234567890'], + ]; + } }