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

move-elevator / composer-translation-validator / 18559927341

16 Oct 2025 11:35AM UTC coverage: 95.519%. Remained the same
18559927341

Pull #73

github

jackd248
build: add php-cs-fixer-preset
Pull Request #73: build: add php-cs-fixer-preset

206 of 210 new or added lines in 16 files covered. (98.1%)

91 existing lines in 20 files now uncovered.

2345 of 2455 relevant lines covered (95.52%)

7.73 hits per line

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

95.95
/src/Validator/HtmlTagValidator.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the "composer-translation-validator" Composer plugin.
7
 *
8
 * (c) 2025 Konrad Michalik <km@move-elevator.de>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace MoveElevator\ComposerTranslationValidator\Validator;
15

16
use MoveElevator\ComposerTranslationValidator\FileDetector\FileSet;
17
use MoveElevator\ComposerTranslationValidator\Parser\{JsonParser, ParserInterface, PhpParser, XliffParser, YamlParser};
18
use MoveElevator\ComposerTranslationValidator\Result\Issue;
19
use Symfony\Component\Console\Helper\{Table, TableStyle};
20
use Symfony\Component\Console\Output\OutputInterface;
21

22
use function count;
23
use function in_array;
24

25
/**
26
 * HtmlTagValidator.
27
 *
28
 * @author Konrad Michalik <km@move-elevator.de>
29
 * @license GPL-3.0-or-later
30
 */
31
class HtmlTagValidator extends AbstractValidator implements ValidatorInterface
32
{
33
    /** @var array<string, array<string, array{value: string, html_structure: array<string, mixed>}>> */
34
    protected array $keyData = [];
35

36
    public function processFile(ParserInterface $file): array
5✔
37
    {
38
        $keys = $file->extractKeys();
5✔
39

40
        if (null === $keys) {
5✔
41
            $this->logger?->error(
1✔
42
                'The source file '.$file->getFileName().' is not valid.',
1✔
43
            );
1✔
44

45
            return [];
1✔
46
        }
47

48
        foreach ($keys as $key) {
4✔
49
            $value = $file->getContentByKey($key);
4✔
50
            if (null === $value) {
4✔
UNCOV
51
                continue;
×
52
            }
53

54
            $htmlStructure = $this->analyzeHtmlStructure($value);
4✔
55
            $fileKey = !empty($this->currentFilePath) ? $this->currentFilePath : $file->getFileName();
4✔
56
            $this->keyData[$key][$fileKey] = [
4✔
57
                'value' => $value,
4✔
58
                'html_structure' => $htmlStructure,
4✔
59
            ];
4✔
60
        }
61

62
        return [];
4✔
63
    }
64

65
    public function postProcess(): void
3✔
66
    {
67
        foreach ($this->keyData as $key => $fileData) {
3✔
68
            $htmlInconsistencies = $this->findHtmlInconsistencies($fileData);
3✔
69

70
            if (!empty($htmlInconsistencies)) {
3✔
71
                $result = [
2✔
72
                    'key' => $key,
2✔
73
                    'files' => $fileData,
2✔
74
                    'inconsistencies' => $htmlInconsistencies,
2✔
75
                ];
2✔
76

77
                $this->addIssue(new Issue(
2✔
78
                    '',
2✔
79
                    $result,
2✔
80
                    '',
2✔
81
                    $this->getShortName(),
2✔
82
                ));
2✔
83
            }
84
        }
85
    }
86

87
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
1✔
88
    {
89
        $details = $issue->getDetails();
1✔
90
        $resultType = $this->resultTypeOnValidationFailure();
1✔
91

92
        $level = $resultType->toString();
1✔
93
        $color = $resultType->toColorString();
1✔
94

95
        $key = $details['key'] ?? 'unknown';
1✔
96
        $inconsistencies = $details['inconsistencies'] ?? [];
1✔
97

98
        $inconsistencyText = implode('; ', $inconsistencies);
1✔
99

100
        return "- <fg={$color}>{$level}</> {$prefix}HTML tag inconsistency in translation key `{$key}` - {$inconsistencyText}";
1✔
101
    }
102

103
    public function distributeIssuesForDisplay(FileSet $fileSet): array
1✔
104
    {
105
        $distribution = [];
1✔
106

107
        foreach ($this->issues as $issue) {
1✔
108
            $details = $issue->getDetails();
1✔
109
            $files = $details['files'] ?? [];
1✔
110

111
            foreach ($files as $filePath => $_) {
1✔
112
                if (!empty($filePath)) {
1✔
113
                    $fileSpecificIssue = new Issue(
1✔
114
                        $filePath,
1✔
115
                        $details,
1✔
116
                        $issue->getParser(),
1✔
117
                        $issue->getValidatorType(),
1✔
118
                    );
1✔
119

120
                    $distribution[$filePath] ??= [];
1✔
121
                    $distribution[$filePath][] = $fileSpecificIssue;
1✔
122
                }
123
            }
124
        }
125

126
        return $distribution;
1✔
127
    }
128

129
    public function renderDetailedOutput(OutputInterface $output, array $issues): void
1✔
130
    {
131
        if (empty($issues)) {
1✔
NEW
132
            return;
×
133
        }
134

135
        $rows = [];
1✔
136
        $allKeys = [];
1✔
137
        $allFilesData = [];
1✔
138

139
        foreach ($issues as $issue) {
1✔
140
            $details = $issue->getDetails();
1✔
141
            $key = $details['key'] ?? 'unknown';
1✔
142
            $files = $details['files'] ?? [];
1✔
143

144
            if (!in_array($key, $allKeys)) {
1✔
145
                $allKeys[] = $key;
1✔
146
            }
147

148
            foreach ($files as $filePath => $fileInfo) {
1✔
149
                $fileName = basename((string) $filePath);
1✔
150
                $value = $fileInfo['value'] ?? '';
1✔
151
                if (!isset($allFilesData[$key])) {
1✔
152
                    $allFilesData[$key] = [];
1✔
153
                }
154
                $allFilesData[$key][$fileName] = $value;
1✔
155
            }
156
        }
157

158
        $firstIssue = $issues[0];
1✔
159
        $firstDetails = $firstIssue->getDetails();
1✔
160
        $firstFiles = $firstDetails['files'] ?? [];
1✔
161

162
        $fileOrder = array_map(static fn ($path) => basename((string) $path), array_keys($firstFiles));
1✔
163

164
        $header = ['Translation Key'];
1✔
165
        foreach ($fileOrder as $fileName) {
1✔
166
            $header[] = $fileName;
1✔
167
        }
168

169
        foreach ($allKeys as $key) {
1✔
170
            $row = [$key];
1✔
171
            foreach ($fileOrder as $fileName) {
1✔
172
                $value = $allFilesData[$key][$fileName] ?? '';
1✔
173
                $row[] = $this->highlightHtmlTags($value);
1✔
174
            }
175
            $rows[] = $row;
1✔
176
        }
177

178
        $table = new Table($output);
1✔
179
        $table->setHeaders($header)
1✔
180
            ->setRows($rows)
1✔
181
            ->setStyle(
1✔
182
                (new TableStyle())
1✔
183
                    ->setCellHeaderFormat('%s'),
1✔
184
            )
1✔
185
            ->render();
1✔
186
    }
187

188
    /**
189
     * @return class-string<ParserInterface>[]
190
     */
191
    public function supportsParser(): array
1✔
192
    {
193
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
194
    }
195

196
    public function resultTypeOnValidationFailure(): ResultType
2✔
197
    {
198
        return ResultType::WARNING;
2✔
199
    }
200

201
    public function shouldShowDetailedOutput(): bool
1✔
202
    {
203
        return true;
1✔
204
    }
205

206
    protected function resetState(): void
1✔
207
    {
208
        parent::resetState();
1✔
209
        $this->keyData = [];
1✔
210
    }
211

212
    /**
213
     * Analyze HTML structure of a translation value.
214
     *
215
     * @return array<string, mixed>
216
     */
217
    private function analyzeHtmlStructure(string $value): array
5✔
218
    {
219
        $structure = [
5✔
220
            'tags' => [],
5✔
221
            'self_closing_tags' => [],
5✔
222
            'attributes' => [],
5✔
223
            'structure_errors' => [],
5✔
224
        ];
5✔
225

226
        // Extract all HTML tags with their attributes
227
        if (preg_match_all('/<(\/?)([\w\-]+)([^>]*)>/i', $value, $matches, \PREG_SET_ORDER)) {
5✔
228
            $tagStack = [];
5✔
229

230
            foreach ($matches as $match) {
5✔
231
                $isClosing = !empty($match[1]);
5✔
232
                $tagName = strtolower($match[2]);
5✔
233
                $attributes = trim($match[3]);
5✔
234

235
                if ($isClosing) {
5✔
236
                    // Closing tag
237
                    if (empty($tagStack) || end($tagStack) !== $tagName) {
5✔
238
                        $structure['structure_errors'][] = "Unmatched closing tag: </{$tagName}>";
1✔
239
                    } else {
240
                        array_pop($tagStack);
5✔
241
                    }
242
                } else {
243
                    // Opening tag or self-closing
244
                    if (str_ends_with($attributes, '/')) {
5✔
245
                        // Self-closing tag
246
                        $structure['self_closing_tags'][] = $tagName;
1✔
247
                        $attributes = rtrim($attributes, ' /');
1✔
248
                    } else {
249
                        // Regular opening tag
250
                        $tagStack[] = $tagName;
5✔
251
                    }
252

253
                    $structure['tags'][] = $tagName;
5✔
254

255
                    // Extract attributes
256
                    if (!empty($attributes)) {
5✔
257
                        $structure['attributes'][$tagName] = $this->extractAttributes($attributes);
1✔
258
                    }
259
                }
260
            }
261

262
            // Check for unclosed tags
263
            foreach ($tagStack as $unclosedTag) {
5✔
264
                $structure['structure_errors'][] = "Unclosed tag: <{$unclosedTag}>";
2✔
265
            }
266
        }
267

268
        return $structure;
5✔
269
    }
270

271
    /**
272
     * Extract attributes from tag attribute string.
273
     *
274
     * @return array<string, string>
275
     */
276
    private function extractAttributes(string $attributeString): array
1✔
277
    {
278
        $attributes = [];
1✔
279

280
        if (preg_match_all('/(\w+)=(["\'])([^"\']*)\2/i', $attributeString, $matches, \PREG_SET_ORDER)) {
1✔
281
            foreach ($matches as $match) {
1✔
282
                $attributes[$match[1]] = $match[3];
1✔
283
            }
284
        }
285

286
        return $attributes;
1✔
287
    }
288

289
    /**
290
     * @param array<string, array{value: string, html_structure: array<string, mixed>}> $fileData
291
     *
292
     * @return array<string>
293
     */
294
    private function findHtmlInconsistencies(array $fileData): array
3✔
295
    {
296
        if (count($fileData) < 2) {
3✔
UNCOV
297
            return [];
×
298
        }
299

300
        $inconsistencies = [];
3✔
301

302
        // Collect all HTML structures from all files for this key
303
        $allStructures = array_map(static fn ($data) => $data['html_structure'], $fileData);
3✔
304

305
        // Compare HTML structures between files
306
        $fileNames = array_keys($allStructures);
3✔
307
        $referenceFile = $fileNames[0];
3✔
308
        $referenceStructure = $allStructures[$referenceFile];
3✔
309

310
        for ($i = 1, $iMax = count($fileNames); $i < $iMax; ++$i) {
3✔
311
            $currentFile = basename($fileNames[$i]);
3✔
312
            $currentStructure = $allStructures[$fileNames[$i]];
3✔
313

314
            // Check tag consistency
315
            $referenceTags = $referenceStructure['tags'] ?? [];
3✔
316
            $currentTags = $currentStructure['tags'] ?? [];
3✔
317

318
            if ($referenceTags !== $currentTags) {
3✔
319
                $missingTags = array_diff($referenceTags, $currentTags);
1✔
320
                $extraTags = array_diff($currentTags, $referenceTags);
1✔
321

322
                if (!empty($missingTags)) {
1✔
323
                    $inconsistencies[] = "File '{$currentFile}' is missing HTML tags: ".implode(', ', array_map(fn ($tag) => "<{$tag}>", $missingTags));
1✔
324
                }
325

326
                if (!empty($extraTags)) {
1✔
327
                    $inconsistencies[] = "File '{$currentFile}' has extra HTML tags: ".implode(', ', array_map(fn ($tag) => "<{$tag}>", $extraTags));
1✔
328
                }
329
            }
330

331
            // Check structure errors
332
            $currentErrors = $currentStructure['structure_errors'] ?? [];
3✔
333
            if (!empty($currentErrors)) {
3✔
334
                $inconsistencies[] = "File '{$currentFile}' has HTML structure errors: ".implode('; ', $currentErrors);
1✔
335
            }
336

337
            // Check attribute consistency for common tags
338
            $referenceAttributes = $referenceStructure['attributes'] ?? [];
3✔
339
            $currentAttributes = $currentStructure['attributes'] ?? [];
3✔
340

341
            foreach ($referenceAttributes as $tagName => $refAttrs) {
3✔
UNCOV
342
                if (isset($currentAttributes[$tagName])) {
×
UNCOV
343
                    $currAttrs = $currentAttributes[$tagName];
×
344

345
                    // Check for class attribute differences (common source of inconsistency)
UNCOV
346
                    if (isset($refAttrs['class']) && isset($currAttrs['class']) && $refAttrs['class'] !== $currAttrs['class']) {
×
UNCOV
347
                        $inconsistencies[] = "File '{$currentFile}' has different class attribute for <{$tagName}>: '{$currAttrs['class']}' vs '{$refAttrs['class']}'";
×
348
                    }
349
                }
350
            }
351
        }
352

353
        return $inconsistencies;
3✔
354
    }
355

356
    private function highlightHtmlTags(string $value): string
1✔
357
    {
358
        $value = preg_replace('/<(\w+)([^>]*)>/', '<fg=cyan><$1$2></>', $value) ?? $value;
1✔
359

360
        return preg_replace('/<\/(\w+)>/', '<fg=magenta></$1></>', $value) ?? $value;
1✔
361
    }
362
}
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

© 2025 Coveralls, Inc