Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.95% covered (warning)
50.95%
161 / 316
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqController
50.95% covered (warning)
50.95%
161 / 316
50.00% covered (danger)
50.00%
3 / 6
384.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add
98.57% covered (success)
98.57%
69 / 70
0.00% covered (danger)
0.00%
0 / 1
7
 solution
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 contentRedirect
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 show
19.47% covered (danger)
19.47%
37 / 190
0.00% covered (danger)
0.00%
0 / 1
601.65
 prepareCommentsData
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
3
1<?php
2
3/**
4 * FAQ Controller
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2002-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2002-09-16
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend;
21
22use phpMyFAQ\Bookmark;
23use phpMyFAQ\Captcha\CaptchaInterface;
24use phpMyFAQ\Captcha\Helper\CaptchaHelperInterface;
25use phpMyFAQ\Category;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Date;
28use phpMyFAQ\Entity\Comment;
29use phpMyFAQ\Entity\SeoEntity;
30use phpMyFAQ\Enums\PermissionType;
31use phpMyFAQ\Enums\SeoType;
32use phpMyFAQ\Faq;
33use phpMyFAQ\Faq\FaqCreationService;
34use phpMyFAQ\Faq\FaqDisplayService;
35use phpMyFAQ\Filter;
36use phpMyFAQ\Language;
37use phpMyFAQ\Link;
38use phpMyFAQ\Link\Util\TitleSlugifier;
39use phpMyFAQ\Mail;
40use phpMyFAQ\Seo;
41use phpMyFAQ\Service\Gravatar;
42use phpMyFAQ\Services;
43use phpMyFAQ\Session\Token;
44use phpMyFAQ\Strings;
45use phpMyFAQ\Translation;
46use phpMyFAQ\Twig\Extensions\LanguageCodeTwigExtension;
47use phpMyFAQ\User\UserSession;
48use phpMyFAQ\Utils;
49use phpMyFAQ\Visits;
50use Symfony\Component\HttpFoundation\RedirectResponse;
51use Symfony\Component\HttpFoundation\Request;
52use Symfony\Component\HttpFoundation\Response;
53use Symfony\Component\Routing\Attribute\Route;
54use Twig\Error\LoaderError;
55use Twig\Extension\AttributeExtension;
56use Twig\TwigFilter;
57
58final class FaqController extends AbstractFrontController
59{
60    /* @mago-expect lint:excessive-parameter-list - the controller dependencies are injected explicitly */
61    public function __construct(
62        private readonly UserSession $faqSession,
63        private readonly CaptchaInterface $captcha,
64        private readonly CaptchaHelperInterface $captchaHelper,
65        private readonly Faq $faq,
66        private readonly Category $category,
67        private readonly Bookmark $bookmark,
68        private readonly Date $date,
69        private readonly Mail $mail,
70        private readonly Gravatar $gravatar,
71    ) {
72        parent::__construct();
73    }
74
75    /**
76     * Displays the form to add a new FAQ
77     *
78     * @throws Exception|LoaderError|\Exception
79     */
80    #[Route(path: '/add-faq.html', name: 'public.faq.add', methods: ['GET'])]
81    public function add(Request $request): Response
82    {
83        $this->faqSession->setCurrentUser($this->currentUser);
84        $this->faqSession->userTracking('new_entry', 0);
85
86        // Get current groups
87        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
88
89        $faqCreationService = new FaqCreationService($this->configuration, $this->currentUser, $currentGroups);
90
91        if (!$faqCreationService->canUserAddFaq()) {
92            if ($this->currentUser->getUserId() === -1) {
93                return new RedirectResponse($this->configuration->getDefaultUrl() . 'login');
94            }
95
96            return new RedirectResponse($this->configuration->getDefaultUrl());
97        }
98
99        $selectedQuestion = Filter::filterVar($request->query->get('question'), FILTER_VALIDATE_INT);
100        $selectedCategory = Filter::filterVar($request->query->get('cat'), FILTER_VALIDATE_INT, -1);
101
102        $faqData = $faqCreationService->prepareAddFaqData($selectedQuestion, $selectedCategory);
103
104        // Add Twig filter
105        $this->addFilter(new TwigFilter('repeat', static fn(
106            mixed $string,
107            mixed $times,
108        ): string => str_repeat((string) $string, max(0, (int) $times))));
109
110        // Prepare template variables
111        $templateVars = [
112            ...$this->getHeader($request),
113            'title' => sprintf(
114                '%s - %s',
115                Translation::getString(key: 'msgAddContent'),
116                $this->configuration->getTitle(),
117            ),
118            'metaDescription' => sprintf(
119                '%s | %s',
120                Translation::getString(key: 'msgNewContentHeader'),
121                $this->configuration->getTitle(),
122            ),
123            'msgNewContentHeader' => Translation::get(key: 'msgNewContentHeader'),
124            'msgNewContentAddon' => Translation::get(key: 'msgNewContentAddon'),
125            'lang' => $this->configuration->getLanguage()->getLanguage(),
126            'openQuestionID' => $faqData['selectedQuestion'],
127            'defaultContentMail' => $faqCreationService->getDefaultUserEmail(),
128            'defaultContentName' => $faqCreationService->getDefaultUserName(),
129            'msgNewContentName' => Translation::get(key: 'msgNewContentName'),
130            'msgNewContentMail' => Translation::get(key: 'msgNewContentMail'),
131            'msgNewContentCategory' => Translation::get(key: 'msgNewContentCategory'),
132            'selectedCategory' => $faqData['selectedCategory'],
133            'categories' => $faqData['categories'],
134            'msgNewContentTheme' => Translation::get(key: 'msgNewContentTheme'),
135            'readonly' => $faqData['readonly'],
136            'question' => $faqData['question'],
137            'msgNewContentArticle' => Translation::get(key: 'msgNewContentArticle'),
138            'msgNewContentKeywords' => Translation::get(key: 'msgNewContentKeywords'),
139            'msgNewContentLink' => Translation::get(key: 'msgNewContentLink'),
140            'captchaFieldset' => $this->captchaHelper->renderCaptcha(
141                $this->captcha,
142                'add',
143                Translation::getString(key: 'msgCaptcha'),
144                $this->currentUser->isLoggedIn(),
145            ),
146            'msgNewContentSubmit' => Translation::get(key: 'msgNewContentSubmit'),
147            'enableWysiwygEditor' => $this->configuration->get('main.enableWysiwygEditorFrontend'),
148            'currentTimestamp' => $request->server->get('REQUEST_TIME'),
149            'msgSeparateKeywordsWithCommas' => Translation::get(key: 'msgSeparateKeywordsWithCommas'),
150            'noCategories' => $faqData['noCategories'],
151            'msgFormDisabledDueToMissingCategories' => Translation::get(key: 'msgFormDisabledDueToMissingCategories'),
152            'displayFullForm' => $faqData['displayFullForm'],
153        ];
154
155        // Collect data for displaying form
156        $formData = is_array($faqData['formData'] ?? null) ? $faqData['formData'] : [];
157        foreach ($formData as $input) {
158            if (!$input instanceof \stdClass) {
159                continue;
160            }
161
162            $active = sprintf('id%d_active', (int) $input->input_id);
163            $label = sprintf('id%d_label', (int) $input->input_id);
164            $required = sprintf('id%d_required', (int) $input->input_id);
165            $templateVars[$active] = (int) $input->input_active !== 0;
166            $templateVars[$label] = $input->input_label;
167            $templateVars[$required] = (int) $input->input_required !== 0 ? 'required' : '';
168        }
169
170        $this->addExtension(new AttributeExtension(LanguageCodeTwigExtension::class));
171        return $this->render('add.twig', $templateVars);
172    }
173
174    /**
175     * Redirects solution_id URLs to the actual FAQ page
176     *
177     * @throws Exception|\Exception
178     */
179    #[Route(path: '/solution_id_{solutionId}.html', name: 'public.faq.solution', methods: ['GET'])]
180    public function solution(Request $request): Response
181    {
182        $solutionId = Filter::filterVar($request->attributes->get('solutionId'), FILTER_VALIDATE_INT, 0);
183
184        if ($solutionId === 0) {
185            return new Response('', Response::HTTP_NOT_FOUND);
186        }
187
188        $faqData = $this->faq->getIdFromSolutionId($solutionId);
189
190        if ($faqData === []) {
191            return new Response('', Response::HTTP_NOT_FOUND);
192        }
193
194        $slug = TitleSlugifier::slug((string) $faqData['question']);
195
196        // Redirect to the canonical FAQ URL
197        $url = sprintf(
198            '/content/%d/%d/%s/%s.html',
199            (int) $faqData['category_id'],
200            (int) $faqData['id'],
201            (string) $faqData['lang'],
202            $slug,
203        );
204
205        return new RedirectResponse($url, Response::HTTP_MOVED_PERMANENTLY);
206    }
207
208    /**
209     * Redirects short content URLs to the full FAQ page
210     *
211     * @throws Exception|\Exception
212     */
213    #[Route(path: '/content/{faqId}/{faqLang}', name: 'public.faq.redirect', methods: ['GET'])]
214    public function contentRedirect(Request $request): Response
215    {
216        $faqId = Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT, 0);
217        $faqLang = Filter::filterVar($request->attributes->get('faqLang'), FILTER_SANITIZE_SPECIAL_CHARS, '');
218
219        if ($faqId === 0 || $faqLang === '') {
220            return new Response('', Response::HTTP_NOT_FOUND);
221        }
222
223        // Query the FAQ data directly for the specified language
224        $result = $this->faq->getFaqResult($faqId, $faqLang);
225
226        if ($this->configuration->getDb()->numRows($result) === 0) {
227            return new Response('', Response::HTTP_NOT_FOUND);
228        }
229
230        $row = $this->configuration->getDb()->fetchObject($result);
231        if (!$row instanceof \stdClass) {
232            return new Response('', Response::HTTP_NOT_FOUND);
233        }
234
235        $categoryId = $this->category->getCategoryIdFromFaq($faqId);
236
237        if ($categoryId === 0) {
238            return new Response('', Response::HTTP_NOT_FOUND);
239        }
240
241        $slug = TitleSlugifier::slug((string) $row->thema);
242
243        // Redirect to the canonical FAQ URL
244        $url = sprintf('/content/%d/%d/%s/%s.html', $categoryId, $faqId, $faqLang, $slug);
245
246        return new RedirectResponse($url, Response::HTTP_MOVED_PERMANENTLY);
247    }
248
249    /**
250     * Displays a single FAQ article with comments, ratings, and related content
251     *
252     * @throws Exception|LoaderError|\Exception
253     */
254    #[Route(path: '/content/{categoryId}/{faqId}/{faqLang}/{slug}.html', name: 'public.faq.show', methods: ['GET'])]
255    public function show(Request $request): Response
256    {
257        $this->faqSession->setCurrentUser($this->currentUser);
258
259        // Get parameters
260        $categoryId = Filter::filterVar($request->attributes->get('categoryId'), FILTER_VALIDATE_INT, 0);
261
262        // Get faqId from route attributes (new routes) or query parameters (legacy/backward compatibility)
263        $faqId = Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT, 0);
264
265        // Get language from route parameter (for /content/ URLs)
266        $requestedLanguage =
267            Filter::filterVar(
268                $request->attributes->get('language'),
269                FILTER_SANITIZE_SPECIAL_CHARS,
270            ) ?? Filter::filterVar(
271                $request->attributes->get('faqLang'),
272                FILTER_SANITIZE_SPECIAL_CHARS,
273            ) ?? $this->configuration->getLanguage()->getLanguage();
274
275        // Temporarily set the language in session for this request
276        $originalLanguage = $this->session->get('lang');
277        if ($requestedLanguage !== $originalLanguage) {
278            $this->session->set('lang', $requestedLanguage);
279            // Update the static language variable
280            Language::$language = $requestedLanguage;
281        }
282
283        $solutionId = Filter::filterVar($request->query->get('solution_id'), FILTER_VALIDATE_INT);
284        $highlight = Filter::filterVar($request->query->get('highlight'), FILTER_SANITIZE_SPECIAL_CHARS);
285        $bookmarkAction = Filter::filterVar($request->query->get('bookmark_action'), FILTER_SANITIZE_SPECIAL_CHARS);
286
287        // Initialize core objects
288        $faq = new Faq($this->configuration);
289        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
290
291        // Handle bookmarks
292        if ($bookmarkAction === 'add' && $faqId > 0) {
293            $this->bookmark->add($faqId);
294        }
295
296        if ($bookmarkAction === 'remove' && $faqId > 0) {
297            $this->bookmark->remove($faqId);
298        }
299
300        // Create a detail service
301        $faqDisplayService = new FaqDisplayService(
302            $this->configuration,
303            $this->currentUser,
304            $currentGroups,
305            $faq,
306            $this->category,
307        );
308
309        // Load FAQ data
310        $faqId = $faqDisplayService->loadFaq($faqId, $solutionId);
311
312        // Do not disclose a FAQ the requester may not see. loadFaq() also returns
313        // non-permitted, inactive, not yet published and expired records, keeping their title,
314        // solution ID, author and update date intact and only replacing the answer with a
315        // placeholder. Checked before the visit is tracked so hidden records leave no trace.
316        if (!$faq->isFaqRecordVisible()) {
317            return new Response('', Response::HTTP_NOT_FOUND);
318        }
319
320        // Track visit
321        $this->faqSession->userTracking('article_view', $faqId);
322        $faqVisits = new Visits($this->configuration);
323        $faqVisits->logViews($faqId);
324
325        // Check if category and FAQ are linked
326        if (!$this->category->categoryHasLinkToFaq($faqId, $categoryId)) {
327            return new Response('', Response::HTTP_NOT_FOUND);
328        }
329
330        // Process content
331        $currentUrl = sprintf('//%s%s', $request->getHost(), $request->getRequestUri());
332        $question = $faqDisplayService->processQuestion($highlight);
333        $answer = $faqDisplayService->processAnswer($currentUrl, $highlight);
334
335        // Get related data
336        $attachmentList = $faqDisplayService->getAttachmentList($faqId);
337        $renderedCategoryPath = $faqDisplayService->getRenderedCategoryPath($faqId);
338        $relatedFaqs = $faqDisplayService->getRelatedFaqs($faqId);
339        $numComments = $faqDisplayService->getNumberOfComments();
340
341        // Do not fetch or render comments when the FAQ record is not accessible to the
342        // current requester (getFaq() flags a denied/non-visible record with solution_id 42).
343        $isFaqAccessible =
344            ($faq->faqRecord['solution_id'] ?? null) !== null && 42 !== (int) $faq->faqRecord['solution_id'];
345        $comments = $isFaqAccessible ? $faqDisplayService->getCommentsData($faqId) : [];
346        $availableLanguages = $faqDisplayService->getAvailableLanguages((int) $faq->faqRecord['id']);
347        $tagsHtml = $faqDisplayService->getTagsHtml($faqId);
348
349        // Generate language URLs with SEO slugs
350        $languageUrls = [];
351        foreach ($availableLanguages as $language) {
352            $url = sprintf(
353                '%scontent/%d/%d/%s/%s.html',
354                $this->configuration->getDefaultUrl(),
355                $categoryId,
356                $faqId,
357                $language,
358                TitleSlugifier::slug($question),
359            );
360            $link = new Link($url, $this->configuration);
361            $link->setTitle($question);
362            $languageUrls[$language] = $link->toString();
363        }
364
365        // Comment permissions
366        $expired = $faqDisplayService->isExpired();
367
368        $commentMessage = sprintf(
369            '%s<a href="#" data-bs-toggle="modal" data-bs-target="#pmf-modal-add-comment">%s</a>',
370            Translation::getString(key: 'msgYouCan'),
371            Translation::getString(key: 'msgWriteComment'),
372        );
373        if (
374            -1 === $this->currentUser->getUserId() && !$this->configuration->get('records.allowCommentsForGuests')
375            || $faq->faqRecord['active'] === 'no'
376            || 'n' === $faq->faqRecord['comment']
377            || $expired
378        ) {
379            $commentMessage = Translation::get(key: 'msgWriteNoComment');
380        }
381
382        // Services for social sharing
383        $faqServices = new Services($this->configuration);
384        $faqServices->setCategoryId($categoryId);
385        $faqServices->setFaqId($faqId);
386        $faqServices->setLanguage($this->configuration->getLanguage()->getLanguage());
387        $faqServices->setQuestion($question);
388
389        // Author visibility (GDPR)
390        $author = $this->currentUser->getUserVisibilityByEmail((string) $faq->faqRecord['email'])
391            ? $faq->faqRecord['author']
392            : 'n/a';
393
394        // SEO
395        $seo = new Seo($this->configuration);
396        $seoEntity = new SeoEntity();
397        $seoEntity
398            ->setSeoType(SeoType::FAQ)
399            ->setReferenceId((int) $faq->faqRecord['id'])
400            ->setReferenceLanguage((string) $faq->faqRecord['lang']);
401        $seoData = $seo->get($seoEntity);
402
403        // Date formatter
404        $date = new Date($this->configuration);
405
406        // Build template variables
407        $templateVars = [
408            ...$this->getHeader($request),
409            'title' => sprintf('%s - %s', $seoData->getTitle() ?? $question, $this->configuration->getTitle()),
410            'metaDescription' => $seoData->getDescription(),
411            'solutionId' => $faq->faqRecord['solution_id'],
412            'solutionIdLink' => './solution_id_' . (string) ($faq->faqRecord['solution_id'] ?? '') . '.html',
413            'breadcrumb' => $this->category->getPathWithStartpage($categoryId, '/', true),
414            'question' => $question,
415            'answer' => $answer,
416            'attachmentList' => $attachmentList,
417            'faqDate' => $date->format((string) $faq->faqRecord['created']),
418            'faqLastChangeDate' => $date->format((string) $faq->faqRecord['date']),
419            'faqAuthor' => $author,
420            'msgPdf' => Translation::get(key: 'msgPDF'),
421            'msgPrintFaq' => Translation::get(key: 'msgPrintArticle'),
422            'enableSendToFriend' => true === $this->configuration->get('main.enableSendToFriend'),
423            'msgShareText' => Translation::get(key: 'msgShareText'),
424            'msgShareViaWhatsapp' => Translation::get(key: 'msgShareViaWhatsapp'),
425            'msgShareFAQ' => Translation::get(key: 'msgShareFAQ'),
426            'linkToPdf' => $faqServices->getPdfLink(),
427            'msgAverageVote' => Translation::get(key: 'msgAverageVote'),
428            'renderVotingResult' => $faqDisplayService->getRating($faqId),
429            'csrfTokenVoting' => Token::getInstance($this->session)->getTokenString('voting'),
430            'languageUrls' => $languageUrls,
431            'currentLanguage' => $faq->faqRecord['lang'],
432            'msgVoteBad' => Translation::get(key: 'msgVoteBad'),
433            'msgVoteGood' => Translation::get(key: 'msgVoteGood'),
434            'msgVoteSubmit' => Translation::get(key: 'msgVoteSubmit'),
435            'msgWriteComment' => Translation::get(key: 'msgWriteComment'),
436            'id' => $faqId,
437            'lang' => $faq->faqRecord['lang'],
438            'msgNewContentName' => Translation::get(key: 'msgNewContentName'),
439            'msgNewContentMail' => Translation::get(key: 'msgNewContentMail'),
440            'defaultContentMail' => $this->currentUser->getUserId() > 0
441            && is_string($contentMail = $this->currentUser->getUserData('email'))
442                ? $contentMail
443                : '',
444            'defaultContentName' => $this->currentUser->getUserId() > 0
445            && is_string($contentName = $this->currentUser->getUserData('display_name'))
446                ? $contentName
447                : '',
448            'msgYourComment' => Translation::get(key: 'msgYourComment'),
449            'msgCancel' => Translation::get(key: 'ad_gen_cancel'),
450            'msgNewContentSubmit' => Translation::get(key: 'msgNewContentSubmit'),
451            'csrfTokenAddComment' => Token::getInstance($this->session)->getTokenString('add-comment'),
452            'enableCommentEditor' => true === $this->configuration->get('main.enableCommentEditor'),
453            'captchaFieldset' => $this->captchaHelper->renderCaptcha(
454                $this->captcha,
455                'writecomment',
456                Translation::getString(key: 'msgCaptcha'),
457                $this->currentUser->isLoggedIn(),
458            ),
459            'comments' => $this->prepareCommentsData($comments),
460            'msgShowMore' => Translation::get(key: 'msgShowMore'),
461            'msgAboutFAQ' => Translation::get(key: 'msgAboutFAQ'),
462            'userId' => $this->currentUser->getUserId(),
463            'permissionEditFaq' => $this->currentUser->perm->hasPermission(
464                $this->currentUser->getUserId(),
465                PermissionType::FAQ_EDIT->value,
466            ),
467            'ad_entry_edit_1' => Translation::get(key: 'ad_entry_edit_1'),
468            'ad_entry_edit_2' => Translation::get(key: 'ad_entry_edit_2'),
469            'bookmarkAction' => $bookmarkAction ?? '',
470            'msgBookmarkAdded' => Translation::get(key: 'msgBookmarkAdded'),
471            'msgBookmarkRemoved' => Translation::get(key: 'msgBookmarkRemoved'),
472            'csrfTokenRemoveBookmark' => Token::getInstance($this->session)->getTokenString('delete-bookmark'),
473            'csrfTokenAddBookmark' => Token::getInstance($this->session)->getTokenString('add-bookmark'),
474            'numberOfComments' => sprintf(
475                '%d %s',
476                $isFaqAccessible ? $numComments[$faqId] ?? 0 : 0,
477                Translation::getString(key: 'msgComments'),
478            ),
479            'writeCommentMsg' => $commentMessage,
480        ];
481
482        // Add conditional variables
483        if (-1 !== $this->currentUser->getUserId()) {
484            $templateVars['bookmarkIcon'] = $this->bookmark->isFaqBookmark($faqId)
485                ? 'bi bi-bookmark-fill'
486                : 'bi bi-bookmark';
487            $templateVars['msgAddBookmark'] = $this->bookmark->isFaqBookmark($faqId)
488                ? Translation::get(key: 'removeBookmark')
489                : Translation::get(key: 'msgAddBookmark');
490            $templateVars['isFaqBookmark'] = $this->bookmark->isFaqBookmark($faqId);
491        }
492
493        if ($availableLanguages !== [] && count($availableLanguages) > 1) {
494            $templateVars['msgChangeLanguage'] = Translation::get(key: 'msgLanguageSubmit');
495        }
496
497        if (
498            $this->currentUser->perm->hasPermission($this->currentUser->getUserId(), PermissionType::FAQ_EDIT->value)
499            && array_key_exists('notes', $faq->faqRecord)
500            && $faq->faqRecord['notes'] !== ''
501        ) {
502            $templateVars['notesHeader'] = Translation::get(key: 'ad_admin_notes');
503            $templateVars['notes'] = $faq->faqRecord['notes'];
504        }
505
506        if ($tagsHtml !== '-') {
507            $templateVars['renderTagsHeader'] = Translation::get(key: 'msg_tags');
508            $templateVars['renderTags'] = $tagsHtml;
509        }
510
511        if ($renderedCategoryPath !== '') {
512            $templateVars['renderRelatedCategoriesHeader'] = Translation::get(key: 'msgArticleCategories');
513            $templateVars['renderRelatedCategories'] = $renderedCategoryPath;
514        }
515
516        if ($relatedFaqs !== '') {
517            $templateVars['renderRelatedArticlesHeader'] = Translation::get(key: 'msg_related_articles');
518            $templateVars['renderRelatedArticles'] = $relatedFaqs;
519        }
520
521        $this->addExtension(new AttributeExtension(LanguageCodeTwigExtension::class));
522        return $this->render('faq.twig', $templateVars);
523    }
524
525    /**
526     * Prepares comment data for the Twig macro
527     *
528     * @param array<array-key, mixed> $comments Array of Comment objects
529     * @throws \Exception
530     * @return array<string, array<array-key, mixed>>
531     */
532    private function prepareCommentsData(array $comments): array
533    {
534        $preparedComments = [];
535        $gravatarImages = [];
536        $safeEmails = [];
537        $formattedDates = [];
538
539        foreach ($comments as $comment) {
540            if (!$comment instanceof Comment) {
541                continue;
542            }
543
544            $commentId = $comment->getId();
545            $preparedComments[] = [
546                'id' => $commentId,
547                'email' => $comment->getEmail(),
548                'username' => Strings::htmlentities($comment->getUsername()),
549                'date' => $comment->getDate(),
550                'comment' => Utils::parseUrl($comment->getComment()),
551            ];
552
553            $gravatarImages[$commentId] = $this->gravatar->getImage($comment->getEmail(), ['class' => 'img-thumbnail']);
554            $safeEmails[$commentId] = $this->mail->safeEmail($comment->getEmail());
555            $formattedDates[$commentId] = $this->date->format($comment->getDate());
556        }
557
558        return [
559            'comments' => $preparedComments,
560            'gravatarImages' => $gravatarImages,
561            'safeEmails' => $safeEmails,
562            'formattedDates' => $formattedDates,
563        ];
564    }
565}