diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index ce4ecbc..35cb232 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -39,6 +39,7 @@ use Djot\Parser\Utility\AttributeParser; use Djot\Parser\Utility\IndentationHelper; use Djot\Renderer\HeadingIdTracker; +use Djot\Util\StringUtil; /** * Block-level parser for Djot @@ -642,7 +643,12 @@ protected function extractFootnotes(array $lines): void // Match footnote definition: [^label]: content (requires whitespace after colon) if (preg_match('/^\[\^([^\]]+)\]:(?:[ \t]+(.*))?[ \t]*$/', $line, $matches)) { - $label = $matches[1]; + // The marker is matched against ONE line, so a definition label + // can never cross a line ending. It is normalized for the same + // reason a link reference definition is (see the `[label]: url` + // branch above): the reference side normalizes too, and the two + // have to meet on the same key. + $label = StringUtil::normalizeLabel($matches[1]); $content = $matches[2] ?? ''; // Determine base indentation (2 spaces for footnotes) diff --git a/src/Parser/InlineParser.php b/src/Parser/InlineParser.php index 04ffcdf..c7bb55d 100644 --- a/src/Parser/InlineParser.php +++ b/src/Parser/InlineParser.php @@ -26,6 +26,7 @@ use Djot\Node\Inline\Text; use Djot\Node\Node; use Djot\Parser\Utility\AttributeParser; +use Djot\Util\StringUtil; /** * Inline parser for Djot @@ -1957,14 +1958,15 @@ protected function applyConsecutiveAttributes(Node $node, string $text, int $sta */ protected function parseFootnoteRef(string $text, int $pos): ?array { - // A footnote label cannot cross a physical line. The definition marker - // is one line too, so accepting a newline here creates an identifier - // that no valid definition can bind (jgm/djot#208). - if (!preg_match('/\G\[\^([^\]\r\n]+)\]/', $text, $matches, 0, $pos)) { + // A footnote REFERENCE may cross a line ending, like a reference link. + // The label is normalized before lookup, so a reference a text editor + // has wrapped still binds to the one-line definition. The definition + // marker itself stays single-line; that half is the block parser's. + if (!preg_match('/\G\[\^([^\]]+)\]/', $text, $matches, 0, $pos)) { return null; } - $label = $matches[1]; + $label = StringUtil::normalizeLabel($matches[1]); // Warn if footnote is not defined if (!$this->blockParser->hasFootnote($label)) { diff --git a/src/Util/StringUtil.php b/src/Util/StringUtil.php index b233596..5ea2c55 100644 --- a/src/Util/StringUtil.php +++ b/src/Util/StringUtil.php @@ -43,4 +43,24 @@ public static function escapeHtml(string $value): string return str_replace("\u{E000}", ' ', $escaped); } + + /** + * Normalize a reference or footnote label for lookup. + * + * Leading and trailing whitespace is removed and every internal run of + * whitespace becomes a single space, so a label a text editor has wrapped + * still matches a definition written on one line. + * + * The character class is exactly djot.js's `normalizeLabel` + * (`label.trim().replace(/[ \t\r\n]+/g, " ")`) rather than PHP's `\s`, + * which also covers form feed and vertical tab. That difference is + * observable: djot.js does not bind `[t][ab]` to `[a b]: url` and a + * `\s`-based normalizer does. + */ + public static function normalizeLabel(string $label): string + { + $collapsed = preg_replace('/[ \t\r\n]+/', ' ', $label) ?? $label; + + return trim($collapsed, " \t\r\n"); + } } diff --git a/tests/TestCase/FootnoteLabelIsSingleLineTest.php b/tests/TestCase/FootnoteLabelIsSingleLineTest.php deleted file mode 100644 index 2d3f6a7..0000000 --- a/tests/TestCase/FootnoteLabelIsSingleLineTest.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ - public static function lineEndings(): iterable - { - yield 'LF' => ["\n"]; - yield 'CRLF' => ["\r\n"]; - yield 'CR' => ["\r"]; - } - - #[DataProvider('lineEndings')] - public function testAReferenceLabelDoesNotCrossALineEnding(string $ending): void - { - $source = "before[^two{$ending}words].\n"; - $converter = new DjotConverter(warnings: true); - $document = $converter->parse($source); - $types = array_map( - static fn ($node): string => $node->getType(), - $document->getChildren()[0]->getChildren(), - ); - - self::assertNotContains('footnote_ref', $types); - self::assertContains('soft_break', $types); - self::assertStringNotContainsString('doc-noteref', $converter->convert($source)); - self::assertSame([], array_filter( - $converter->getWarnings(), - static fn ($warning): bool => str_contains($warning->getMessage(), 'Undefined footnote'), - )); - } - - public function testAMultilineDefinitionMarkerDoesNotRegisterOrSwallowText(): void - { - $source = "see[^two words].\n\n[^two\nwords]: note.\n"; - $converter = new DjotConverter(); - - $document = $converter->parse($source); - self::assertNotContains('footnote', array_map( - static fn ($node): string => $node->getType(), - $document->getChildren(), - )); - self::assertStringContainsString("[^two\nwords]: note.", $converter->convert($source)); - } - - /** - * @return iterable - */ - public static function sameLineLabels(): iterable - { - yield 'space' => ['two words']; - yield 'tab' => ["two\twords"]; - } - - #[DataProvider('sameLineLabels')] - public function testSameLineWhitespaceStillResolves(string $label): void - { - $html = (new DjotConverter())->convert("see[^{$label}].\n\n[^{$label}]: note.\n"); - self::assertStringContainsString('doc-noteref', $html); - } -} diff --git a/tests/TestCase/FootnoteLabelNormalizationTest.php b/tests/TestCase/FootnoteLabelNormalizationTest.php new file mode 100644 index 0000000..c6b299f --- /dev/null +++ b/tests/TestCase/FootnoteLabelNormalizationTest.php @@ -0,0 +1,116 @@ + + */ + public static function lineEndings(): iterable + { + yield 'LF' => ["\n"]; + yield 'CRLF' => ["\r\n"]; + yield 'CR' => ["\r"]; + } + + #[DataProvider('lineEndings')] + public function testAWrappedReferenceBindsToTheOneLineDefinition(string $ending): void + { + $source = "before[^two{$ending}words].\n\n[^two words]: note.\n"; + $converter = new DjotConverter(warnings: true); + + $html = $converter->convert($source); + + self::assertStringContainsString('doc-noteref', $html); + self::assertStringContainsString('note.', $html); + self::assertSame([], array_filter( + $converter->getWarnings(), + static fn ($warning): bool => str_contains($warning->getMessage(), 'Undefined footnote'), + )); + } + + public function testAWrappedReferenceKeepsTheNormalizedLabelInTheAst(): void + { + $document = (new DjotConverter())->parse("see[^two\n words].\n\n[^two words]: note.\n"); + + $refs = []; + foreach ($document->getChildren()[0]->getChildren() as $node) { + if ($node->getType() === 'footnote_ref') { + $refs[] = $node; + } + } + + self::assertCount(1, $refs); + self::assertSame('two words', $refs[0]->getLabel()); + } + + /** + * @return iterable + */ + public static function whitespaceVariants(): iterable + { + yield 'a run of spaces in the reference' => ['two words', 'two words']; + yield 'a tab in the reference' => ["two\twords", 'two words']; + yield 'padding around the reference' => [' two words ', 'two words']; + yield 'a run of spaces in the definition' => ['two words', 'two words']; + yield 'a tab in the definition' => ['two words', "two\twords"]; + } + + #[DataProvider('whitespaceVariants')] + public function testWhitespaceIsNormalizedOnBothSides(string $reference, string $definition): void + { + $html = (new DjotConverter())->convert("see[^{$reference}].\n\n[^{$definition}]: note.\n"); + + self::assertStringContainsString('doc-noteref', $html); + self::assertStringContainsString('note.', $html); + } + + public function testADefinitionMarkerDoesNotCrossALineEnding(): void + { + $source = "see[^two words].\n\n[^two\nwords]: note.\n"; + $converter = new DjotConverter(); + + $document = $converter->parse($source); + + self::assertNotContains('footnote', array_map( + static fn ($node): string => $node->getType(), + $document->getChildren(), + )); + + // The line is a paragraph, so its own `[^two\nwords]` is an ordinary + // (unresolved) reference and the `: note.` tail stays visible text. + $html = $converter->convert($source); + self::assertStringContainsString(': note.', $html); + } + + public function testAnUndefinedWrappedReferenceWarnsUnderItsNormalizedLabel(): void + { + $converter = new DjotConverter(warnings: true); + $converter->convert("see[^two\nwords].\n"); + + $messages = array_map( + static fn ($warning): string => $warning->getMessage(), + $converter->getWarnings(), + ); + + self::assertNotSame([], array_filter( + $messages, + static fn (string $message): bool => str_contains($message, 'two words'), + )); + } +}