Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.89% covered (success)
95.89%
70 / 73
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentMentionParserService
95.83% covered (success)
95.83%
69 / 72
85.71% covered (warning)
85.71%
6 / 7
8
0.00% covered (danger)
0.00%
0 / 1
 parseToHtml
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 parseMarkdown
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
1
 parseContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceEmoticons
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 highlightMentions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 highlightRecordReferences
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 extractMentionTokens
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Comments\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * Service parsing rich text comments: @mentions, #record references, and emoji emoticons.
13 *
14 * Provides safe HTML transformation preserving XSS protection.
15 *
16 * @package App\Modules\Comments\Application\Service
17 */
18final readonly class CommentMentionParserService
19{
20    private const array EMOTICON_MAP = [
21        ':)'   => '😊',
22        ':-)'  => '😊',
23        ':D'   => '😃',
24        ':-D'  => '😃',
25        ';)'   => '😉',
26        ';-)'  => '😉',
27        ':P'   => '😋',
28        ':-P'  => '😋',
29        ':p'   => '😋',
30        '<3'   => '❤️',
31        '(y)'  => '👍',
32        ':('   => '🙁',
33        ':-('  => '🙁',
34        ':o'   => '😮',
35        ':-o'  => '😮',
36        ':O'   => '😮',
37        ':-O'  => '😮',
38        '8)'   => '😎',
39        '8-)'  => '😎',
40    ];
41
42    /**
43     * Parses raw input text into safe, enhanced HTML with markdown, mentions, records and emoticons.
44     *
45     * @param string $rawText Raw user comment input.
46     * @return string Safe sanitized HTML snippet.
47     */
48    public function parseToHtml(string $rawText): string
49    {
50        $escaped = htmlspecialchars($rawText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
51        $withMarkdown = $this->parseMarkdown($escaped);
52        $withEmoticons = $this->replaceEmoticons($withMarkdown);
53        $withMentions = $this->highlightMentions($withEmoticons);
54        $withRecords = $this->highlightRecordReferences($withMentions);
55
56        return nl2br($withRecords);
57    }
58
59    /**
60     * Parses safe markdown formatting elements into HTML tags.
61     */
62    public function parseMarkdown(string $text): string
63    {
64        // 1. Code blocks (```lang ... ```)
65        $text = (string) preg_replace(
66            '/```(?:[a-zA-Z0-9_\-]+)?\n?(.*?)\n?```/s',
67            '<pre class="comment-code-block"><code>$1</code></pre>',
68            $text
69        );
70
71        // 2. Inline code (`code`)
72        $text = (string) preg_replace(
73            '/`([^`\n]+)`/',
74            '<code class="comment-code-inline">$1</code>',
75            $text
76        );
77
78        // 3. Markdown links [text](url)
79        $text = (string) preg_replace(
80            '/\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\)\s]+)\)/',
81            '<a href="$2" target="_blank" rel="noopener noreferrer" class="comment-link">$1</a>',
82            $text
83        );
84
85        // 4. Bold (**text**)
86        $text = (string) preg_replace(
87            '/\*\*([^\*\n]+)\*\*/',
88            '<strong>$1</strong>',
89            $text
90        );
91
92        // 5. Italic (*text*)
93        $text = (string) preg_replace(
94            '/(?<!\*)\*([^\*\n]+)\*(?!\*)/',
95            '<em>$1</em>',
96            $text
97        );
98
99        // 6. Underline (__text__)
100        $text = (string) preg_replace(
101            '/__([^_\n]+)__/',
102            '<u>$1</u>',
103            $text
104        );
105
106        // 7. Strikethrough (~text~ or ~~text~~)
107        $text = (string) preg_replace(
108            '/~{1,2}([^~\n]+)~{1,2}/',
109            '<del>$1</del>',
110            $text
111        );
112
113        // 8. Blockquote (> text)
114        return (string) preg_replace(
115            '/(?<=^|\n)&gt;\s?(.*)/',
116            '<blockquote class="comment-blockquote">$1</blockquote>',
117            $text
118        );
119    }
120
121    /**
122     * Alias for parseToHtml.
123     */
124    public function parseContent(string $rawText): string
125    {
126        return $this->parseToHtml($rawText);
127    }
128
129    /**
130     * Replaces classic ASCII text smileys with Unicode emoji characters.
131     */
132    public function replaceEmoticons(string $text): string
133    {
134        return str_replace(array_keys(self::EMOTICON_MAP), array_values(self::EMOTICON_MAP), $text);
135    }
136
137    /**
138     * Converts @username or @firstname_lastname into highlighted mention chips.
139     */
140    public function highlightMentions(string $text): string
141    {
142        return (string) preg_replace(
143            '/(?<=^|\s)@([a-zA-Z0-9_\.\-\p{L}]+)/u',
144            '<span class="comment-mention"><i class="bi bi-at" aria-hidden="true"></i>$1</span>',
145            $text
146        );
147    }
148
149    /**
150     * Converts #RECORD-123, #Record_Title, or #[Record Title] references into styled record chips.
151     */
152    public function highlightRecordReferences(string $text): string
153    {
154        // 1. Bracketed references: #[Title with spaces]
155        $text = (string) preg_replace(
156            '/(?<=^|\s)#\[([^\]\r\n]+)\]/u',
157            '<span class="comment-record-ref" data-ref="$1"><i class="bi bi-hash" aria-hidden="true"></i>$1</span>',
158            $text
159        );
160
161        // 2. Standard token references: #Record_Title or #123
162        return (string) preg_replace_callback(
163            '/(?<=^|\s)#([\p{L}\p{N}_\.\-\/]+)/u',
164            static function (array $matches): string {
165                $ref = $matches[1];
166                $display = str_replace('_', ' ', $ref);
167
168                return '<span class="comment-record-ref" data-ref="' . $ref . '">'
169                    . '<i class="bi bi-hash" aria-hidden="true"></i>' . $display
170                    . '</span>';
171            },
172            $text
173        );
174    }
175
176    /**
177     * Extracts all distinct mention tokens (@token) from raw or formatted text.
178     *
179     * @param string $text Input text.
180     * @return array<int, string> List of lowercased tokens without the '@' prefix.
181     */
182    public function extractMentionTokens(string $text): array
183    {
184        if (preg_match_all('/(?<=^|\s)@([a-zA-Z0-9_\.\-\p{L}]+)/u', $text, $matches)) {
185            return array_values(array_unique(array_map('mb_strtolower', $matches[1])));
186        }
187
188        return [];
189    }
190}