From e332b4710ae88cc341b2c4ea04c4208911d16f70 Mon Sep 17 00:00:00 2001 From: Aidan Woods Date: Mon, 4 May 2020 22:02:20 +0100 Subject: [PATCH 1/4] Add HeaderSlug configurable Adds HeaderSlug configurable, with the option for the slug function to be customised. Co-authored-by: netniV --- src/Components/Blocks/Header.php | 10 +++- src/Components/Blocks/SetextHeader.php | 10 +++- src/Configurables/HeaderSlug.php | 64 ++++++++++++++++++++++ tests/ParsedownTest.php | 2 + tests/data/slug_heading.html | 10 ++++ tests/data/slug_heading.md | 24 ++++++++ tests/src/Configurables/HeaderSlugTest.php | 38 +++++++++++++ 7 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/Configurables/HeaderSlug.php create mode 100644 tests/data/slug_heading.html create mode 100644 tests/data/slug_heading.md create mode 100644 tests/src/Configurables/HeaderSlugTest.php diff --git a/src/Components/Blocks/Header.php b/src/Components/Blocks/Header.php index ae2180c..cbdfae1 100644 --- a/src/Components/Blocks/Header.php +++ b/src/Components/Blocks/Header.php @@ -5,6 +5,7 @@ namespace Erusev\Parsedown\Components\Blocks; use Erusev\Parsedown\AST\Handler; use Erusev\Parsedown\AST\StateRenderable; use Erusev\Parsedown\Components\Block; +use Erusev\Parsedown\Configurables\HeaderSlug; use Erusev\Parsedown\Configurables\StrictMode; use Erusev\Parsedown\Html\Renderables\Element; use Erusev\Parsedown\Parsedown; @@ -96,9 +97,16 @@ final class Header implements Block return new Handler( /** @return Element */ function (State $State) { + $HeaderSlug = $State->get(HeaderSlug::class); + $attributes = ( + $HeaderSlug->isEnabled() + ? ['id' => $HeaderSlug->transform($this->text())] + : [] + ); + return new Element( 'h' . \strval($this->level()), - [], + $attributes, $State->applyTo(Parsedown::line($this->text(), $State)) ); } diff --git a/src/Components/Blocks/SetextHeader.php b/src/Components/Blocks/SetextHeader.php index 4e1a5df..dbdf04b 100644 --- a/src/Components/Blocks/SetextHeader.php +++ b/src/Components/Blocks/SetextHeader.php @@ -6,6 +6,7 @@ use Erusev\Parsedown\AST\Handler; use Erusev\Parsedown\AST\StateRenderable; use Erusev\Parsedown\Components\AcquisitioningBlock; use Erusev\Parsedown\Components\Block; +use Erusev\Parsedown\Configurables\HeaderSlug; use Erusev\Parsedown\Html\Renderables\Element; use Erusev\Parsedown\Parsedown; use Erusev\Parsedown\Parsing\Context; @@ -88,9 +89,16 @@ final class SetextHeader implements AcquisitioningBlock return new Handler( /** @return Element */ function (State $State) { + $HeaderSlug = $State->get(HeaderSlug::class); + $attributes = ( + $HeaderSlug->isEnabled() + ? ['id' => $HeaderSlug->transform($this->text())] + : [] + ); + return new Element( 'h' . \strval($this->level()), - [], + $attributes, $State->applyTo(Parsedown::line($this->text(), $State)) ); } diff --git a/src/Configurables/HeaderSlug.php b/src/Configurables/HeaderSlug.php new file mode 100644 index 0000000..a2e75eb --- /dev/null +++ b/src/Configurables/HeaderSlug.php @@ -0,0 +1,64 @@ +enabled = $enabled; + + if (! isset($slugCallback)) { + $this->slugCallback = function (string $text): string { + $slug = \mb_strtolower($text); + $slug = \str_replace(' ', '-', $slug); + $slug = \preg_replace('/[^\p{L}\p{N}\p{M}-]+/u', '', $slug); + + return $slug; + }; + } else { + $this->slugCallback = $slugCallback; + } + } + + /** @return bool */ + public function isEnabled() + { + return $this->enabled; + } + + public function transform(string $text): string + { + return ($this->slugCallback)($text); + } + + /** @param \Closure(string):string $slugCallback */ + public static function withCallback($slugCallback): self + { + return new self(true, $slugCallback); + } + + /** @return self */ + public static function enabled() + { + return new self(true); + } + + /** @return self */ + public static function initial() + { + return new self(false); + } +} diff --git a/tests/ParsedownTest.php b/tests/ParsedownTest.php index d463279..f0833c5 100755 --- a/tests/ParsedownTest.php +++ b/tests/ParsedownTest.php @@ -6,6 +6,7 @@ use Erusev\Parsedown\Components\Blocks\Markup as BlockMarkup; use Erusev\Parsedown\Components\Inlines\Markup as InlineMarkup; use Erusev\Parsedown\Configurables\BlockTypes; use Erusev\Parsedown\Configurables\Breaks; +use Erusev\Parsedown\Configurables\HeaderSlug; use Erusev\Parsedown\Configurables\InlineTypes; use Erusev\Parsedown\Configurables\SafeMode; use Erusev\Parsedown\Configurables\StrictMode; @@ -59,6 +60,7 @@ class ParsedownTest extends TestCase new SafeMode(\substr($test, 0, 3) === 'xss'), new StrictMode(\substr($test, 0, 6) === 'strict'), new Breaks(\substr($test, 0, 14) === 'breaks_enabled'), + new HeaderSlug(\substr($test, 0, 4) === 'slug'), ])); $actualMarkup = $Parsedown->toHtml($markdown); diff --git a/tests/data/slug_heading.html b/tests/data/slug_heading.html new file mode 100644 index 0000000..d379bb4 --- /dev/null +++ b/tests/data/slug_heading.html @@ -0,0 +1,10 @@ +

foo

+

foo bar

+

foo_bar

+

foo+bar

+

2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි

+

foo

+

foo bar

+

foo_bar

+

foo+bar

+

2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි

\ No newline at end of file diff --git a/tests/data/slug_heading.md b/tests/data/slug_heading.md new file mode 100644 index 0000000..01f66d6 --- /dev/null +++ b/tests/data/slug_heading.md @@ -0,0 +1,24 @@ +# foo + +# foo bar + +# foo_bar + +# foo+bar + +# 2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි + +foo +--- + +foo bar +--- + +foo_bar +--- + +foo+bar +--- + +2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි +--- \ No newline at end of file diff --git a/tests/src/Configurables/HeaderSlugTest.php b/tests/src/Configurables/HeaderSlugTest.php new file mode 100644 index 0000000..7b4806a --- /dev/null +++ b/tests/src/Configurables/HeaderSlugTest.php @@ -0,0 +1,38 @@ +assertSame(true, $State->get(HeaderSlug::class)->isEnabled()); + } + /** + * @return void + * @throws \PHPUnit\Framework\ExpectationFailedException + * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException + */ + public function testCustomCallback() + { + $HeaderSlug = HeaderSlug::withCallback(function (string $t): string { + return \preg_replace('/[^A-Za-z0-9]++/', '_', $t); + }); + + $this->assertSame( + 'foo_bar', + $HeaderSlug->transform('foo bar') + ); + } +} From d8bf07535c7621b6994c9cb2196c996a9b9acc01 Mon Sep 17 00:00:00 2001 From: Aidan Woods Date: Mon, 4 May 2020 22:37:38 +0100 Subject: [PATCH 2/4] Strip superscripts and divisions from eventual slug As suggested by @Ayesh Co-authored-by: Ayesh Karunaratne --- src/Configurables/HeaderSlug.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Configurables/HeaderSlug.php b/src/Configurables/HeaderSlug.php index a2e75eb..103faeb 100644 --- a/src/Configurables/HeaderSlug.php +++ b/src/Configurables/HeaderSlug.php @@ -24,7 +24,7 @@ final class HeaderSlug implements Configurable $this->slugCallback = function (string $text): string { $slug = \mb_strtolower($text); $slug = \str_replace(' ', '-', $slug); - $slug = \preg_replace('/[^\p{L}\p{N}\p{M}-]+/u', '', $slug); + $slug = \preg_replace('/[^\p{L}\p{Nd}\p{Nl}\p{M}-]+/u', '', $slug); return $slug; }; From 4e99e29d286b057d6c89baf746fd721096445fe9 Mon Sep 17 00:00:00 2001 From: Aidan Woods Date: Tue, 5 May 2020 19:11:02 +0100 Subject: [PATCH 3/4] Trim leading and trailing hyphens from slug --- src/Configurables/HeaderSlug.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Configurables/HeaderSlug.php b/src/Configurables/HeaderSlug.php index 103faeb..2f32699 100644 --- a/src/Configurables/HeaderSlug.php +++ b/src/Configurables/HeaderSlug.php @@ -25,6 +25,7 @@ final class HeaderSlug implements Configurable $slug = \mb_strtolower($text); $slug = \str_replace(' ', '-', $slug); $slug = \preg_replace('/[^\p{L}\p{Nd}\p{Nl}\p{M}-]+/u', '', $slug); + $slug = \trim($slug, '-'); return $slug; }; From 8764512c23f2a68565bc8060117dc65f9c89fd9c Mon Sep 17 00:00:00 2001 From: Aidan Woods Date: Tue, 5 May 2020 19:42:32 +0100 Subject: [PATCH 4/4] Add SlugRegister so IDs are not duplicated --- src/Components/Blocks/Header.php | 4 +- src/Components/Blocks/SetextHeader.php | 4 +- src/Configurables/HeaderSlug.php | 43 +++++++++++++++++++-- src/Configurables/SlugRegister.php | 44 ++++++++++++++++++++++ tests/data/slug_heading.html | 13 ++++--- tests/data/slug_heading.md | 2 + tests/src/Configurables/HeaderSlugTest.php | 24 +++++++++++- 7 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 src/Configurables/SlugRegister.php diff --git a/src/Components/Blocks/Header.php b/src/Components/Blocks/Header.php index cbdfae1..c3859fe 100644 --- a/src/Components/Blocks/Header.php +++ b/src/Components/Blocks/Header.php @@ -6,6 +6,7 @@ use Erusev\Parsedown\AST\Handler; use Erusev\Parsedown\AST\StateRenderable; use Erusev\Parsedown\Components\Block; use Erusev\Parsedown\Configurables\HeaderSlug; +use Erusev\Parsedown\Configurables\SlugRegister; use Erusev\Parsedown\Configurables\StrictMode; use Erusev\Parsedown\Html\Renderables\Element; use Erusev\Parsedown\Parsedown; @@ -98,9 +99,10 @@ final class Header implements Block /** @return Element */ function (State $State) { $HeaderSlug = $State->get(HeaderSlug::class); + $Register = $State->get(SlugRegister::class); $attributes = ( $HeaderSlug->isEnabled() - ? ['id' => $HeaderSlug->transform($this->text())] + ? ['id' => $HeaderSlug->transform($Register, $this->text())] : [] ); diff --git a/src/Components/Blocks/SetextHeader.php b/src/Components/Blocks/SetextHeader.php index dbdf04b..482e37a 100644 --- a/src/Components/Blocks/SetextHeader.php +++ b/src/Components/Blocks/SetextHeader.php @@ -7,6 +7,7 @@ use Erusev\Parsedown\AST\StateRenderable; use Erusev\Parsedown\Components\AcquisitioningBlock; use Erusev\Parsedown\Components\Block; use Erusev\Parsedown\Configurables\HeaderSlug; +use Erusev\Parsedown\Configurables\SlugRegister; use Erusev\Parsedown\Html\Renderables\Element; use Erusev\Parsedown\Parsedown; use Erusev\Parsedown\Parsing\Context; @@ -90,9 +91,10 @@ final class SetextHeader implements AcquisitioningBlock /** @return Element */ function (State $State) { $HeaderSlug = $State->get(HeaderSlug::class); + $Register = $State->get(SlugRegister::class); $attributes = ( $HeaderSlug->isEnabled() - ? ['id' => $HeaderSlug->transform($this->text())] + ? ['id' => $HeaderSlug->transform($Register, $this->text())] : [] ); diff --git a/src/Configurables/HeaderSlug.php b/src/Configurables/HeaderSlug.php index 2f32699..a0bc3c3 100644 --- a/src/Configurables/HeaderSlug.php +++ b/src/Configurables/HeaderSlug.php @@ -12,12 +12,19 @@ final class HeaderSlug implements Configurable /** @var \Closure(string):string */ private $slugCallback; + /** @var \Closure(string,int):string */ + private $duplicationCallback; + /** * @param bool $enabled * @param (\Closure(string):string)|null $slugCallback + * @param (\Closure(string, int):string)|null $duplicationCallback */ - public function __construct($enabled, $slugCallback = null) - { + public function __construct( + $enabled, + $slugCallback = null, + $duplicationCallback = null + ) { $this->enabled = $enabled; if (! isset($slugCallback)) { @@ -32,6 +39,14 @@ final class HeaderSlug implements Configurable } else { $this->slugCallback = $slugCallback; } + + if (! isset($duplicationCallback)) { + $this->duplicationCallback = function (string $slug, int $duplicateNumber): string { + return $slug . '-' . \strval($duplicateNumber-1); + }; + } else { + $this->duplicationCallback = $duplicationCallback; + } } /** @return bool */ @@ -40,9 +55,23 @@ final class HeaderSlug implements Configurable return $this->enabled; } - public function transform(string $text): string + public function transform(SlugRegister $SlugRegister, string $text): string { - return ($this->slugCallback)($text); + $slug = ($this->slugCallback)($text); + + if ($SlugRegister->slugCount($slug) > 0) { + $newSlug = ($this->duplicationCallback)($slug, $SlugRegister->mutatingIncrement($slug)); + + while ($SlugRegister->slugCount($newSlug) > 0) { + $newSlug = ($this->duplicationCallback)($slug, $SlugRegister->mutatingIncrement($slug)); + } + + return $newSlug; + } + + $SlugRegister->mutatingIncrement($slug); + + return $slug; } /** @param \Closure(string):string $slugCallback */ @@ -51,6 +80,12 @@ final class HeaderSlug implements Configurable return new self(true, $slugCallback); } + /** @param \Closure(string,int):string $duplicationCallback */ + public static function withDuplicationCallback($duplicationCallback): self + { + return new self(true, null, $duplicationCallback); + } + /** @return self */ public static function enabled() { diff --git a/src/Configurables/SlugRegister.php b/src/Configurables/SlugRegister.php new file mode 100644 index 0000000..1a659e0 --- /dev/null +++ b/src/Configurables/SlugRegister.php @@ -0,0 +1,44 @@ + */ + private $register; + + /** + * @param array $register + */ + public function __construct(array $register = []) + { + $this->register = $register; + } + + /** @return self */ + public static function initial() + { + return new self; + } + + public function mutatingIncrement(string $slug): int + { + if (! isset($this->register[$slug])) { + $this->register[$slug] = 0; + } + + return ++$this->register[$slug]; + } + + public function slugCount(string $slug): int + { + return $this->register[$slug] ?? 0; + } + + public function isolatedCopy(): self + { + return new self($this->register); + } +} diff --git a/tests/data/slug_heading.html b/tests/data/slug_heading.html index d379bb4..56b6021 100644 --- a/tests/data/slug_heading.html +++ b/tests/data/slug_heading.html @@ -1,10 +1,11 @@

foo

foo bar

foo_bar

-

foo+bar

+

foo+bar-1

+

foo+bar

2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි

-

foo

-

foo bar

-

foo_bar

-

foo+bar

-

2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි

\ No newline at end of file +

foo

+

foo bar

+

foo_bar

+

foo+bar

+

2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි

\ No newline at end of file diff --git a/tests/data/slug_heading.md b/tests/data/slug_heading.md index 01f66d6..2317bb5 100644 --- a/tests/data/slug_heading.md +++ b/tests/data/slug_heading.md @@ -4,6 +4,8 @@ # foo_bar +# foo+bar-1 + # foo+bar # 2rer*(0👍ගම්මැද්ද V FORCE ඉනොවේශන් නේෂන් සඳහා එවූ නි diff --git a/tests/src/Configurables/HeaderSlugTest.php b/tests/src/Configurables/HeaderSlugTest.php index 7b4806a..e831a70 100644 --- a/tests/src/Configurables/HeaderSlugTest.php +++ b/tests/src/Configurables/HeaderSlugTest.php @@ -3,6 +3,7 @@ namespace Erusev\Parsedown\Tests\Configurables; use Erusev\Parsedown\Configurables\HeaderSlug; +use Erusev\Parsedown\Configurables\SlugRegister; use Erusev\Parsedown\State; use PHPUnit\Framework\TestCase; @@ -19,6 +20,7 @@ final class HeaderSlugTest extends TestCase $this->assertSame(true, $State->get(HeaderSlug::class)->isEnabled()); } + /** * @return void * @throws \PHPUnit\Framework\ExpectationFailedException @@ -32,7 +34,27 @@ final class HeaderSlugTest extends TestCase $this->assertSame( 'foo_bar', - $HeaderSlug->transform('foo bar') + $HeaderSlug->transform(SlugRegister::initial(), 'foo bar') + ); + } + + /** + * @return void + * @throws \PHPUnit\Framework\ExpectationFailedException + * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException + */ + public function testCustomDuplicationCallback() + { + $HeaderSlug = HeaderSlug::withDuplicationCallback(function (string $t, int $n): string { + return $t . '_' . \strval($n-1); + }); + + $SlugRegister = new SlugRegister; + $HeaderSlug->transform($SlugRegister, 'foo bar'); + + $this->assertSame( + 'foo-bar_1', + $HeaderSlug->transform($SlugRegister, 'foo bar') ); } }