From 8e924a0534dbe3de97572062d9e89966666effeb Mon Sep 17 00:00:00 2001 From: RobinDev Date: Mon, 14 Sep 2026 23:14:43 +0200 Subject: [PATCH] fix: require whitespace after an ATX heading marker --- src/Rules/HeadingRule.php | 16 +++++++++++++- tests/Rules/HeadingRuleTest.php | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/Rules/HeadingRule.php b/src/Rules/HeadingRule.php index 52f38d6..edc05e7 100644 --- a/src/Rules/HeadingRule.php +++ b/src/Rules/HeadingRule.php @@ -22,7 +22,21 @@ public function __construct( public function shouldParse(Parser $parser): bool { - return $parser->comesNext('#', 1); + if (! $parser->comesNext('#', 1)) { + return false; + } + + // An ATX heading is one to six `#` followed by whitespace or by the + // end of the line; `#title` and `#######` are paragraphs. + $level = strspn($parser->content, '#', $parser->position); + + if ($level > 6) { + return false; + } + + $next = $parser->content[$parser->position + $level] ?? null; + + return $next === null || str_contains(Parser::WHITESPACE, $next); } public function parse(Parser $parser): Token diff --git a/tests/Rules/HeadingRuleTest.php b/tests/Rules/HeadingRuleTest.php index e1346ee..314d0a0 100644 --- a/tests/Rules/HeadingRuleTest.php +++ b/tests/Rules/HeadingRuleTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\Markdown\Parser; use Tempest\Markdown\Rules\HeadingRule; +use Tempest\Markdown\Rules\ParagraphRule; use Tempest\Markdown\Tests\ParserTestCase; class HeadingRuleTest extends ParserTestCase @@ -111,4 +112,40 @@ public function lex_keeps_an_explicit_id_without_generated_ids(): void $this->assertSame('

A heading

', $html); } + + #[Test] + public function lex_without_a_space_after_the_marker_is_not_a_heading(): void + { + $parser = new Parser(highlighter: null, rules: [ + new HeadingRule(), + new ParagraphRule(), + ]); + + $this->assertSame('

#titre

', (string) $parser->parse('#titre')); + $this->assertSame( + '

#hashtag

', + (string) $parser->parse('#hashtag'), + ); + $this->assertSame('

#5 bolt

', (string) $parser->parse('#5 bolt')); + } + + #[Test] + public function lex_more_than_six_markers_is_not_a_heading(): void + { + $html = (string) new Parser(highlighter: null, rules: [ + new HeadingRule(), + new ParagraphRule(), + ])->parse('####### seven'); + + $this->assertSame('

####### seven

', $html); + } + + #[Test] + public function lex_marker_alone_is_an_empty_heading(): void + { + $parser = new Parser(highlighter: null, rules: [new HeadingRule()]); + + $this->assertSame('

', (string) $parser->parse('#')); + $this->assertSame('

', (string) $parser->parse('##')); + } }