• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / Yoast-SEO-for-TYPO3 / 21521134747

30 Jan 2026 03:30PM UTC coverage: 0.866% (-0.4%) from 1.275%
21521134747

push

github

RinyVT
[FEATURE] Version 12.0.0, added v14 support, removed v11 support including php8.0 and php8.1, rewrote backend javascript functionality to typescript and webcomponents

0 of 550 new or added lines in 53 files covered. (0.0%)

33 existing lines in 21 files now uncovered.

23 of 2657 relevant lines covered (0.87%)

0.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/Classes/Service/LinkingSuggestionsService.php
1
<?php
2

3
/**
4
 * This file is part of the "yoast_seo" extension for TYPO3 CMS.
5
 *
6
 * For the full copyright and license information, please read the
7
 * LICENSE.txt file that was distributed with this source code.
8
 */
9

10
declare(strict_types=1);
11

12
namespace YoastSeoForTypo3\YoastSeo\Service;
13

14
use TYPO3\CMS\Backend\Utility\BackendUtility;
15
use TYPO3\CMS\Core\Context\LanguageAspect;
16
use TYPO3\CMS\Core\Database\Connection;
17
use TYPO3\CMS\Core\Database\ConnectionPool;
18
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
19
use TYPO3\CMS\Core\Utility\GeneralUtility;
20
use YoastSeoForTypo3\YoastSeo\Traits\LanguageServiceTrait;
21

22
class LinkingSuggestionsService
23
{
24
    use LanguageServiceTrait;
25

26
    protected const PROMINENT_WORDS_TABLE = 'tx_yoastseo_prominent_word';
27

28
    protected int $excludePageId;
29
    protected int $site;
30
    protected int $languageId;
31

32
    public function __construct(
33
        protected ConnectionPool $connectionPool,
34
        protected PageRepository $pageRepository,
35
        protected SiteService $siteService,
UNCOV
36
    ) {}
×
37

38
    /**
39
     * @param array<array{occurrences: int, stem: string}> $words
40
     * @return array<array<string, mixed>>
41
     */
42
    public function getLinkingSuggestions(
43
        array $words,
44
        int $excludePageId,
45
        int $languageId,
46
        string $content
47
    ): array {
48
        if ($words === []) {
×
49
            return [];
×
50
        }
51
        $this->excludePageId = $excludePageId;
×
NEW
52
        $this->site = $this->siteService->getSiteRootPageId($excludePageId);
×
53
        $this->languageId = $languageId;
×
54

NEW
55
        $words = array_column($words, 'occurrences', 'stem');
×
56

57
        // Combine stems, weights and DFs from request
58
        $requestData = $this->composeRequestData($words);
×
59

60
        // Calculate vector length of the request set (needed for score normalization later)
61
        $requestVectorLength = $this->computeVectorLength($requestData);
×
62

63
        $requestStems = array_keys($requestData);
×
64
        $scores = [];
×
NEW
65
        $batchSize = 100;
×
66
        $page = 1;
×
67

68
        do {
69
            // Retrieve the words of all records in this batch that share prominent word stems with request
70
            $candidatesWords = $this->getCandidateWords($requestStems, $batchSize, $page);
×
71

72
            // Transform the prominent words table so that it indexed by record
73
            $candidatesWordsByRecord = $this->groupWordsByRecord($candidatesWords);
×
74

75
            $batchScoresSize = 0;
×
76
            foreach ($candidatesWordsByRecord as $id => $candidateData) {
×
77
                $scores[$id] = $this->calculateScoreForIndexable($requestData, $requestVectorLength, $candidateData);
×
78
                ++$batchScoresSize;
×
79
            }
80

81
            // Sort the list of scores and keep only the top of the scores
82
            $scores = $this->getTopSuggestions($scores);
×
83

84
            ++$page;
×
85
        } while ($batchScoresSize === $batchSize);
×
86

87
        // Return the empty list if no suggestions have been found.
88
        if ($scores === []) {
×
89
            return [];
×
90
        }
91

92
        return $this->linkRecords($scores, $this->getCurrentContentLinks($content));
×
93
    }
94

95
    /**
96
     * @param array<string, int|string> $requestWords
97
     * @return array<string, array{weight: int, df: int}>
98
     */
99
    protected function composeRequestData(array $requestWords): array
100
    {
101
        $requestDocFrequencies = $this->countDocumentFrequencies(array_keys($requestWords));
×
102
        $combinedRequestData = [];
×
103
        foreach ($requestWords as $stem => $weight) {
×
104
            if (!isset($requestDocFrequencies[$stem])) {
×
105
                continue;
×
106
            }
107

108
            $combinedRequestData[$stem] = [
×
109
                'weight' => (int)$weight,
×
110
                'df' => $requestDocFrequencies[$stem],
×
111
            ];
×
112
        }
113
        return $combinedRequestData;
×
114
    }
115

116
    /**
117
     * @param string[] $stems
118
     * @return array<string, int>
119
     */
120
    protected function countDocumentFrequencies(array $stems): array
121
    {
122
        if ($stems === []) {
×
123
            return [];
×
124
        }
125

126
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PROMINENT_WORDS_TABLE);
×
NEW
127
        $rawDocFrequencies = $queryBuilder->select('stem')->addSelectLiteral('COUNT(stem) AS document_frequency')->from(
×
NEW
128
            self::PROMINENT_WORDS_TABLE
×
NEW
129
        )->where(
×
NEW
130
            $queryBuilder->expr()->in(
×
NEW
131
                'stem',
×
NEW
132
                $queryBuilder->createNamedParameter($stems, Connection::PARAM_STR_ARRAY)
×
NEW
133
            ),
×
NEW
134
            $queryBuilder->expr()->eq('sys_language_uid', $this->languageId),
×
NEW
135
            $queryBuilder->expr()->eq('site', $this->site)
×
NEW
136
        )->groupBy('stem')->executeQuery()->fetchAllAssociative();
×
137

138
        $stems = array_map(
×
139
            static function ($item) {
×
140
                return $item['stem'];
×
141
            },
×
142
            $rawDocFrequencies
×
143
        );
×
144

145
        $docFrequencies = array_fill_keys($stems, 0);
×
146
        foreach ($rawDocFrequencies as $rawDocFrequency) {
×
147
            $docFrequencies[$rawDocFrequency['stem']] = (int)$rawDocFrequency['document_frequency'];
×
148
        }
149
        return $docFrequencies;
×
150
    }
151

152
    /**
153
     * @param array<string, array{weight: int, df: int}> $prominentWords
154
     */
155
    protected function computeVectorLength(array $prominentWords): float
156
    {
157
        $sumOfSquares = 0;
×
158
        foreach ($prominentWords as $word) {
×
159
            $docFrequency = 1;
×
160
            if (array_key_exists('df', $word)) {
×
161
                $docFrequency = $word['df'];
×
162
            }
163

164
            $tfIdf = $this->computeTfIdfScore($word['weight'], $docFrequency);
×
165
            $sumOfSquares += ($tfIdf ** 2);
×
166
        }
167
        return sqrt($sumOfSquares);
×
168
    }
169

170
    protected function computeTfIdfScore(int $termFrequency, int $docFrequency): float
171
    {
172
        $docFrequency = max(1, $docFrequency);
×
173
        return $termFrequency * (1 / $docFrequency);
×
174
    }
175

176
    /**
177
     * @param string[] $stems
178
     * @return array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int, df?: int}>
179
     */
180
    protected function getCandidateWords(array $stems, int $batchSize, int $page): array
181
    {
182
        return $this->findStemsByRecords(
×
183
            $this->findRecordsByStems($stems, $batchSize, $page)
×
184
        );
×
185
    }
186

187
    /**
188
     * @param array<array{pid: int, tablenames: string}> $records
189
     * @return array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int, df?: int}>
190
     */
191
    protected function findStemsByRecords(array $records): array
192
    {
193
        if ($records === []) {
×
194
            return [];
×
195
        }
196

197
        $prominentWords = $this->getProminentWords($records);
×
198
        $prominentStems = array_column($prominentWords, 'stem');
×
199

200
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PROMINENT_WORDS_TABLE);
×
NEW
201
        $documentFreqs = $queryBuilder->select('stem')->addSelectLiteral('COUNT(uid) AS count')->from(
×
NEW
202
            self::PROMINENT_WORDS_TABLE
×
NEW
203
        )->where(
×
NEW
204
            $queryBuilder->expr()->in(
×
NEW
205
                'stem',
×
NEW
206
                $queryBuilder->createNamedParameter($prominentStems, Connection::PARAM_STR_ARRAY)
×
NEW
207
            ),
×
NEW
208
            $queryBuilder->expr()->eq('site', $this->site),
×
NEW
209
            $queryBuilder->expr()->eq('sys_language_uid', $this->languageId)
×
NEW
210
        )->groupBy('stem')->executeQuery()->fetchAllAssociative();
×
211

212
        $stemCounts = [];
×
213
        foreach ($documentFreqs as $documentFreq) {
×
214
            $stemCounts[$documentFreq['stem']] = $documentFreq['count'];
×
215
        }
216

217
        foreach ($prominentWords as &$prominentWord) {
×
218
            if (!array_key_exists($prominentWord['stem'], $stemCounts)) {
×
219
                continue;
×
220
            }
221
            $prominentWord['df'] = (int)$stemCounts[$prominentWord['stem']];
×
222
        }
223
        return $prominentWords;
×
224
    }
225

226
    /**
227
     * @param string[] $stems
228
     * @return array<array{pid: int, tablenames: string}>
229
     */
230
    protected function findRecordsByStems(array $stems, int $batchSize, int $page): array
231
    {
232
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PROMINENT_WORDS_TABLE);
×
NEW
233
        $queryBuilder->select('pid', 'tablenames')->from(self::PROMINENT_WORDS_TABLE)->where(
×
NEW
234
            $queryBuilder->expr()->in(
×
NEW
235
                'stem',
×
NEW
236
                $queryBuilder->createNamedParameter($stems, Connection::PARAM_STR_ARRAY)
×
NEW
237
            ),
×
NEW
238
            $queryBuilder->expr()->eq('sys_language_uid', $this->languageId),
×
NEW
239
            $queryBuilder->expr()->eq('site', $this->site)
×
NEW
240
        )->setMaxResults($batchSize)->setFirstResult(($page - 1) * $batchSize);
×
241
        /** @var array<array{pid: int, tablenames: string}> $records */
242
        $records = $queryBuilder->executeQuery()->fetchAllAssociative();
×
243
        return $records;
×
244
    }
245

246
    /**
247
     * @param array<array{pid: int, tablenames: string}> $records
248
     * @return array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int}>
249
     */
250
    protected function getProminentWords(array $records): array
251
    {
252
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PROMINENT_WORDS_TABLE);
×
253
        $orStatements = [];
×
254
        foreach ($records as $record) {
×
255
            $orStatements[] = $queryBuilder->expr()->and(
×
256
                $queryBuilder->expr()->eq('pid', $record['pid']),
×
257
                $queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter($record['tablenames'])),
×
258
                $queryBuilder->expr()->eq(
×
259
                    'sys_language_uid',
×
260
                    $queryBuilder->createNamedParameter($this->languageId, Connection::PARAM_INT)
×
261
                )
×
262
            );
×
263
        }
NEW
264
        $queryBuilder->select('stem', 'weight', 'pid', 'tablenames', 'uid_foreign')->from(self::PROMINENT_WORDS_TABLE)
×
265
            ->where(
×
266
                $queryBuilder->expr()->or(...$orStatements)
×
267
            );
×
268
        /** @var array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int}> $prominentWords */
269
        $prominentWords = $queryBuilder->executeQuery()->fetchAllAssociative();
×
270
        return $prominentWords;
×
271
    }
272

273
    /**
274
     * @param array<array{stem: string, weight: int, pid: int, tablenames: string, uid_foreign: int, df?: int}> $candidateWords
275
     * @return array<string, array<string, array{weight: int, df: int}>>
276
     */
277
    protected function groupWordsByRecord(array $candidateWords): array
278
    {
279
        $candidateWordsByRecords = [];
×
280
        foreach ($candidateWords as $candidateWord) {
×
281
            if (!isset($candidateWord['df'])) {
×
282
                continue;
×
283
            }
284
            $recordKey = $candidateWord['uid_foreign'] . '-' . $candidateWord['tablenames'];
×
285
            $candidateWordsByRecords[$recordKey][$candidateWord['stem']] = [
×
286
                'weight' => (int)$candidateWord['weight'],
×
287
                'df' => (int)$candidateWord['df'],
×
288
            ];
×
289
        }
290
        return $candidateWordsByRecords;
×
291
    }
292

293
    /**
294
     * @param array<string, array{weight: int, df: int}> $requestData
295
     * @param array<string, array{weight: int, df: int}> $candidateData
296
     */
297
    protected function calculateScoreForIndexable(
298
        array $requestData,
299
        float $requestVectorLength,
300
        array $candidateData
301
    ): float {
302
        $rawScore = $this->computeRawScore($requestData, $candidateData);
×
303
        $candidateVectorLength = $this->computeVectorLength($candidateData);
×
304
        return $this->normalizeScore($rawScore, $candidateVectorLength, $requestVectorLength);
×
305
    }
306

307
    /**
308
     * @param array<string, array{weight: int, df: int}> $requestData
309
     * @param array<string, array{weight: int, df: int}> $candidateData
310
     */
311
    protected function computeRawScore(array $requestData, array $candidateData): float
312
    {
313
        $rawScore = 0;
×
314
        foreach ($candidateData as $stem => $candidateWordData) {
×
315
            if (!array_key_exists($stem, $requestData)) {
×
316
                continue;
×
317
            }
318

319
            $wordFromRequestWeight = $requestData[$stem]['weight'];
×
320
            $wordFromRequestDf = $requestData[$stem]['df'];
×
321
            $candidateWeight = $candidateWordData['weight'];
×
322
            $canidateDf = $candidateWordData['df'];
×
323

324
            $tfIdfFromRequest = $this->computeTfIdfScore($wordFromRequestWeight, $wordFromRequestDf);
×
325
            $tfIdfFromDatabase = $this->computeTfIdfScore($candidateWeight, $canidateDf);
×
326

327
            $rawScore += ($tfIdfFromRequest * $tfIdfFromDatabase);
×
328
        }
329
        return (float)$rawScore;
×
330
    }
331

332
    protected function normalizeScore(float $rawScore, float $vectorLengthCandidate, float $vectorLengthRequest): float
333
    {
334
        $normalizingFactor = $vectorLengthRequest * $vectorLengthCandidate;
×
335
        if ($normalizingFactor === 0.0) {
×
336
            // We can't divide by 0, so set the score to 0 instead.
337
            return 0;
×
338
        }
339
        return (float)($rawScore / $normalizingFactor);
×
340
    }
341

342
    /**
343
     * @param array<string, float|int> $scores
344
     * @return array<string, float|int>
345
     */
346
    protected function getTopSuggestions(array $scores): array
347
    {
348
        // Sort the indexables by descending score.
349
        uasort(
×
350
            $scores,
×
351
            static function ($score1, $score2) {
×
352
                if ($score1 === $score2) {
×
353
                    return 0;
×
354
                }
355
                return ($score1 < $score2) ? 1 : -1;
×
356
            }
×
357
        );
×
358

359
        // Take the top $limit suggestions, while preserving their ids specified in the keys of the array elements.
360
        return \array_slice($scores, 0, 20, true);
×
361
    }
362

363
    /**
364
     * @param array<string, float|int> $scores
365
     * @param array<string, bool> $currentLinks
366
     * @return array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}>
367
     */
368
    protected function linkRecords(array $scores, array $currentLinks): array
369
    {
370
        $links = [];
×
371
        foreach ($scores as $record => $score) {
×
372
            [$uid, $table] = explode('-', $record);
×
373
            if ($table === 'pages' && (int)$uid === $this->excludePageId) {
×
374
                continue;
×
375
            }
376

377
            $data = BackendUtility::getRecord($table, $uid);
×
378
            if ($data === null) {
×
379
                continue;
×
380
            }
NEW
381
            if ($this->languageId > 0 && ($overlay = $this->getRecordOverlay($table, $data, $this->languageId))) {
×
382
                $data = $overlay;
×
383
            }
384

385
            $labelField = $GLOBALS['TCA'][$table]['ctrl']['label'];
×
386

387
            $links[$record] = [
×
388
                'label' => $data[$labelField],
×
389
                'recordType' => $this->getRecordType($table),
×
390
                'id' => $uid,
×
391
                'table' => $table,
×
392
                'cornerstone' => (int)($data['tx_yoastseo_cornerstone'] ?? 0),
×
393
                'score' => $score,
×
394
                'active' => isset($currentLinks[$record]),
×
395
            ];
×
396
        }
397
        $this->sortSuggestions($links);
×
398

399
        $cornerStoneSuggestions = $this->filterSuggestions($links, true);
×
400
        $nonCornerStoneSuggestions = $this->filterSuggestions($links, false);
×
401

402
        return array_merge_recursive([], $cornerStoneSuggestions, $nonCornerStoneSuggestions);
×
403
    }
404

405
    /**
406
     * @param array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}> $links
407
     */
408
    protected function sortSuggestions(array &$links): void
409
    {
410
        usort(
×
411
            $links,
×
412
            static function ($suggestion1, $suggestion2) {
×
413
                if ($suggestion1['score'] === $suggestion2['score']) {
×
414
                    return 0;
×
415
                }
416

417
                return ($suggestion1['score'] < $suggestion2['score']) ? 1 : -1;
×
418
            }
×
419
        );
×
420
    }
421

422
    /**
423
     * @param array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}> $links
424
     * @return array<string, array{label: string, recordType: string, id: int, table: string, cornerstone: int, score: float, active: bool}>
425
     */
426
    protected function filterSuggestions(array $links, bool $cornerstone): array
427
    {
428
        return \array_filter(
×
429
            $links,
×
430
            static function ($suggestion) use ($cornerstone) {
×
431
                return (bool)$suggestion['cornerstone'] === $cornerstone;
×
432
            }
×
433
        );
×
434
    }
435

436
    /**
437
     * @return array<string, bool>
438
     */
439
    protected function getCurrentContentLinks(string $content): array
440
    {
441
        $currentLinks = [];
×
442
        preg_match_all('/<a href="t3:\/\/(.*)\?uid=([\d]+)/', $content, $matches, PREG_SET_ORDER);
×
443
        foreach ($matches as $match) {
×
444
            $key = (int)$match[2] . '-' . str_replace('page', 'pages', $match[1]);
×
445
            $currentLinks[$key] = true;
×
446
        }
447
        return $currentLinks;
×
448
    }
449

450
    protected function getRecordType(string $table): string
451
    {
452
        return $this->getLanguageService()->sL(
×
453
            $GLOBALS['TCA'][$table]['ctrl']['title']
×
454
        );
×
455
    }
456

457
    /**
458
     * @param array<string, mixed> $data
459
     * @return array<string, mixed>|null
460
     */
461
    protected function getRecordOverlay(string $table, array $data, int $languageId): array|null
462
    {
463
        $languageAspect = GeneralUtility::makeInstance(LanguageAspect::class, $languageId, $languageId, 'mixed');
×
464
        return $this->pageRepository->getLanguageOverlay($table, $data, $languageAspect);
×
465
    }
466
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc