Repository: thephpleague/commonmark (php)

Issue:
Abbreviated decade (e.g. ’80s) breaks smart punctuation
### Version(s) affected

2.4.2

### Description

When the `QuoteParser` encounters a string such as:

```
In the middle to late ’90s, it was chaos. "We couldn't get out of that rut."
```

... it seemingly includes the apostrophe before 90 in its calculations, and you end up with a string like:

```
In the middle to late ‘90s, it was chaos. “We couldn’t get out of that rut.“
```

... where it replaces the already formatted closing single quote before 90 (which is how abbreviated decades should be formatted) with an opening single quote, and then the final double quote ends up as another opening double quote instead of a closing double quote.

I don't know whether the solution lies in the `QuoteParser` being aware of the abbreviated decade format or having the option to instruct it to disregard already formatted quotes or specify it to only format unformatted quotes.

I'm migrating a Node-based site that uses `markdown-it` to PHP, and `markdown-it` handles this situation properly, so there does seem to be a way to interpret this.

### How to reproduce

I tested this with the `GrahamCampbell/Laravel-Markdown` package as well as using `thephpleague/commonmark` directly, so the problem does seem to lie within this package in either the `QuoteParser` or `QuoteProcessor`.

To reproduce, use any decade abbreviation (e.g. `'60s` unstyled or `’70s` styled) in a paragraph with other quoted text. Both will result in the wrong formatting.

Pull request description:
Don't change already-formatted quotation marks
Fixes #1030 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1045aa08..db4e2ec7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
 
 ## [Unreleased][unreleased]
 
+### Fixed
+
+- Fixed SmartPunct extension changing already-formatted quotation marks (#1030)
+
 ## [2.4.3] - 2024-07-22
 
 ### Fixed
diff --git a/src/Extension/SmartPunct/QuoteParser.php b/src/Extension/SmartPunct/QuoteParser.php
index b77d0855..959930b3 100644
--- a/src/Extension/SmartPunct/QuoteParser.php
+++ b/src/Extension/SmartPunct/QuoteParser.php
@@ -24,12 +24,19 @@ use League\CommonMark\Util\RegexHelper;
 
 final class QuoteParser implements InlineParserInterface
 {
+    /**
+     * @deprecated This constant is no longer used and will be removed in a future major release
+     */
     public const DOUBLE_QUOTES = [Quote::DOUBLE_QUOTE, Quote::DOUBLE_QUOTE_OPENER, Quote::DOUBLE_QUOTE_CLOSER];
+
+    /**
+     * @deprecated This constant is no longer used and will be removed in a future major release
+     */
     public const SINGLE_QUOTES = [Quote::SINGLE_QUOTE, Quote::SINGLE_QUOTE_OPENER, Quote::SINGLE_QUOTE_CLOSER];
 
     public function getMatchDefinition(): InlineParserMatch
     {
-        return InlineParserMatch::oneOf(...[...self::DOUBLE_QUOTES, ...self::SINGLE_QUOTES]);
+        return InlineParserMatch::oneOf(Quote::SINGLE_QUOTE, Quote::DOUBLE_QUOTE);
     }
 
     /**
@@ -40,8 +47,6 @@ final class QuoteParser implements InlineParserInterface
         $char   = $inlineContext->getFullMatch();
         $cursor = $inlineContext->getCursor();
 
-        $normalizedCharacter = $this->getNormalizedQuoteCharacter($char);
-
         $charBefore = $cursor->peek(-1);
         if ($charBefore === null) {
             $charBefore = "\n";
@@ -58,28 +63,15 @@ final class QuoteParser implements InlineParserInterface
         $canOpen                        = $leftFlanking && ! $rightFlanking;
         $canClose                       = $rightFlanking;
 
-        $node = new Quote($normalizedCharacter, ['delim' => true]);
+        $node = new Quote($char, ['delim' => true]);
         $inlineContext->getContainer()->appendChild($node);
 
         // Add entry to stack to this opener
-        $inlineContext->getDelimiterStack()->push(new Delimiter($normalizedCharacter, 1, $node, $canOpen, $canClose));
+        $inlineContext->getDelimiterStack()->push(new Delimiter($char, 1, $node, $canOpen, $canClose));
 
         return true;
     }
 
-    private function getNormalizedQuoteCharacter(string $character): string
-    {
-        if (\in_array($character, self::DOUBLE_QUOTES, true)) {
-            return Quote::DOUBLE_QUOTE;
-        }
-
-        if (\in_array($character, self::SINGLE_QUOTES, true)) {
-            return Quote::SINGLE_QUOTE;
-        }
-
-        return $character;
-    }
-
     /**
      * @return bool[]
      */


Test patch:
diff --git a/tests/functional/Extension/SmartPunct/SmartPunctFunctionalTest.php b/tests/functional/Extension/SmartPunct/SmartPunctFunctionalTest.php
index 0f372c6b..402ca809 100644
--- a/tests/functional/Extension/SmartPunct/SmartPunctFunctionalTest.php
+++ b/tests/functional/Extension/SmartPunct/SmartPunctFunctionalTest.php
@@ -40,5 +40,15 @@ final class SmartPunctFunctionalTest extends AbstractSpecTestCase
     public static function dataProvider(): \Generator
     {
         yield from SpecReader::readFile(__DIR__ . '/../../../../vendor/commonmark/commonmark.js/test/smart_punct.txt');
+
+        yield 'Existing formatted quotes should be preserved (issue #1030)' => [
+            'input'   => 'In the middle to late ’90s, it was chaos. "We couldn\'t get out of that rut."',
+            'output'  => "<p>In the middle to late ’90s, it was chaos. “We couldn’t get out of that rut.”</p>\n",
+        ];
+
+        yield 'already-formatted quotes are kept as-is' => [
+            'input'   => '"Plain quotes", “normal quotes”, and ”backwards quotes“',
+            'output'  => "<p>“Plain quotes”, “normal quotes”, and ”backwards quotes“</p>\n",
+        ];
     }
 }