Skip to content

Commit 50f189e

Browse files
Merge pull request #119 from maikschneider/target-language-check
Validate target-language in XLF files
2 parents 3578b44 + 6dae368 commit 50f189e

4 files changed

Lines changed: 409 additions & 26 deletions

File tree

src/Parser/XliffParser.php

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
use InvalidArgumentException;
1717
use SimpleXMLElement;
1818

19+
use function preg_match;
20+
use function strtolower;
21+
1922
/**
2023
* XliffParser.
2124
*
@@ -112,14 +115,58 @@ public static function getSupportedFileExtensions(): array
112115

113116
public function getLanguage(): string
114117
{
115-
if (preg_match(
116-
'/^([a-z]{2})\./i',
117-
$this->getFileName(),
118-
$matches,
119-
)) {
120-
return $matches[1];
118+
return $this->getLanguageFromFileName() ?? $this->getSourceLanguage();
119+
}
120+
121+
public function isVersion2(): bool
122+
{
123+
return $this->isVersion2;
124+
}
125+
126+
/**
127+
* Extracts the expected locale from the filename, supporting both
128+
* prefix convention (de.locallang.xlf, TYPO3 style) and
129+
* suffix convention (messages.de.xlf, Symfony/Laravel style).
130+
* Returns null if the filename carries no locale.
131+
*/
132+
public function getLanguageFromFileName(): ?string
133+
{
134+
$fileName = $this->getFileName();
135+
136+
// Prefix convention: de.locallang.xlf, de_AT.locallang.xlf, de_DE.locallang.xlf
137+
if (preg_match('/^([a-z]{2})(?:[-_][A-Z]{2})?\./i', $fileName, $matches)) {
138+
return strtolower($matches[1]);
121139
}
122140

141+
// Suffix convention: messages.de.xlf, messages.de_AT.xlf, messages.de_DE.xlf
142+
if (preg_match('/\.([a-z]{2})(?:[-_][A-Z]{2})?\.(?:xlf|xliff)$/i', $fileName, $matches)) {
143+
return strtolower($matches[1]);
144+
}
145+
146+
return null;
147+
}
148+
149+
/**
150+
* Returns the normalized target language declared in the XLIFF file, or null if not set.
151+
* Region suffix is stripped to match getLanguageFromFileName() behavior (e.g. "de-AT" → "de").
152+
* XLIFF 1.x: target-language attribute on <file>.
153+
* XLIFF 2.x: trgLang attribute on <xliff>.
154+
*/
155+
public function getTargetLanguage(): ?string
156+
{
157+
$lang = $this->isVersion2
158+
? (string) ($this->xml['trgLang'] ?? '')
159+
: (string) ($this->xml->file['target-language'] ?? '');
160+
161+
if ('' === $lang) {
162+
return null;
163+
}
164+
165+
return strtolower((string) preg_replace('/[-_][^-_]+$/', '', $lang));
166+
}
167+
168+
private function getSourceLanguage(): string
169+
{
123170
if ($this->isVersion2) {
124171
return (string) ($this->xml['srcLang'] ?? '');
125172
}

src/Validator/XliffSchemaValidator.php

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\Translation\Util\XliffUtils;
2121

2222
use function sprintf;
23+
use function strtolower;
2324

2425
/**
2526
* XliffSchemaValidator.
@@ -31,40 +32,79 @@ class XliffSchemaValidator extends AbstractValidator implements ValidatorInterfa
3132
{
3233
public function processFile(ParserInterface $file): array
3334
{
34-
try {
35-
/*
36-
* With XmlUtils::loadFile() we always get a strange symfony error related to global composer autoloading issue.
37-
* Call to undefined method Symfony\Component\Filesystem\Filesystem::readFile()
38-
*/
39-
if (!file_exists($file->getFilePath())) {
40-
$this->logger?->error('File does not exist: '.$file->getFileName());
41-
42-
return [];
43-
}
35+
/*
36+
* With XmlUtils::loadFile() we always get a strange symfony error related to global composer autoloading issue.
37+
* Call to undefined method Symfony\Component\Filesystem\Filesystem::readFile()
38+
*/
39+
if (!file_exists($file->getFilePath())) {
40+
$this->logger?->error('File does not exist: '.$file->getFileName());
41+
42+
return [];
43+
}
4444

45-
$fileContent = file_get_contents($file->getFilePath());
46-
if (false === $fileContent) {
47-
$this->logger?->error('Failed to read file: '.$file->getFileName());
45+
$fileContent = file_get_contents($file->getFilePath());
46+
if (false === $fileContent) {
47+
$this->logger?->error('Failed to read file: '.$file->getFileName());
4848

49-
return [];
50-
}
49+
return [];
50+
}
51+
52+
try {
5153
$dom = XmlUtils::parse($fileContent);
54+
} catch (Exception $e) {
55+
$this->logger?->error('Failed to parse XML: '.$e->getMessage());
56+
57+
return [];
58+
}
59+
60+
// Schema validation — may throw for unsupported XLIFF versions (e.g. 2.x)
61+
$errors = [];
62+
try {
5263
$errors = XliffUtils::validateSchema($dom);
5364
} catch (Exception $e) {
5465
if (str_contains($e->getMessage(), 'No support implemented for loading XLIFF version')) {
5566
$this->logger?->notice(sprintf('Skipping %s: %s', $this->getShortName(), $e->getMessage()));
5667
} else {
5768
$this->logger?->error('Failed to validate XML schema: '.$e->getMessage());
5869
}
59-
60-
return [];
6170
}
6271

63-
if (!empty($errors)) {
72+
// Additional check: if filename encodes a locale, verify it matches target-language in the file header
73+
if (!$file instanceof XliffParser) {
6474
return $errors;
6575
}
6676

67-
return [];
77+
$expectedLanguage = $file->getLanguageFromFileName();
78+
if (null !== $expectedLanguage) {
79+
$targetLang = $file->getTargetLanguage();
80+
$isVersion2 = $file->isVersion2();
81+
$attribute = $isVersion2 ? 'trgLang' : 'target-language';
82+
$element = $isVersion2 ? '<xliff>' : '<file>';
83+
84+
if (null === $targetLang) {
85+
$errors[] = [
86+
'message' => sprintf(
87+
'Missing "%s" attribute on %s node; expected "%s" based on filename',
88+
$attribute,
89+
$element,
90+
$expectedLanguage,
91+
),
92+
'level' => 'ERROR',
93+
];
94+
} elseif (strtolower($targetLang) !== $expectedLanguage) {
95+
$errors[] = [
96+
'message' => sprintf(
97+
'"%s" attribute "%s" does not match filename language "%s"',
98+
$attribute,
99+
$targetLang,
100+
$expectedLanguage,
101+
),
102+
'level' => 'ERROR',
103+
];
104+
}
105+
}
106+
107+
return $errors;
68108
}
69109

70110
public function formatIssueMessage(Issue $issue, string $prefix = ''): string

tests/src/Parser/XliffParserTest.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,114 @@ public function testGetLanguageFromSrcLangAttributeXliff2(): void
411411
$this->assertSame('en', $parser->getLanguage());
412412
}
413413

414+
public function testGetLanguageFromFileNamePrefixConvention(): void
415+
{
416+
$parser = new XliffParser($this->prefixedXliffFile); // de.messages.xlf
417+
$this->assertSame('de', $parser->getLanguageFromFileName());
418+
}
419+
420+
public function testGetLanguageFromFileNameSuffixConvention(): void
421+
{
422+
$parser = new XliffParser($this->targetLanguageXliffFile); // messages.de.xlf
423+
$this->assertSame('de', $parser->getLanguageFromFileName());
424+
}
425+
426+
public function testGetLanguageFromFileNamePrefixConventionWithRegion(): void
427+
{
428+
$file = $this->tempDir.'/de_DE.locallang.xlf';
429+
file_put_contents($file, $this->prefixedXliffContent);
430+
431+
$parser = new XliffParser($file);
432+
$this->assertSame('de', $parser->getLanguageFromFileName());
433+
}
434+
435+
public function testGetLanguageFromFileNameSuffixConventionWithRegion(): void
436+
{
437+
$file = $this->tempDir.'/messages.de_DE.xlf';
438+
file_put_contents($file, $this->targetLanguageXliffContent);
439+
440+
$parser = new XliffParser($file);
441+
$this->assertSame('de', $parser->getLanguageFromFileName());
442+
}
443+
444+
public function testGetLanguageFromFileNameReturnsNullForSourceFile(): void
445+
{
446+
$parser = new XliffParser($this->validXliffFile); // messages.xlf — no locale
447+
$this->assertNull($parser->getLanguageFromFileName());
448+
}
449+
450+
public function testGetLanguageFromFileNameReturnsNullForXliff2SourceFile(): void
451+
{
452+
$parser = new XliffParser($this->xliff2File); // messages_v2.xlf — no locale
453+
$this->assertNull($parser->getLanguageFromFileName());
454+
}
455+
456+
public function testGetTargetLanguageReturnsValueWhenSet(): void
457+
{
458+
$parser = new XliffParser($this->targetLanguageXliffFile); // target-language="de"
459+
$this->assertSame('de', $parser->getTargetLanguage());
460+
}
461+
462+
public function testGetTargetLanguageReturnsNullWhenNotSet(): void
463+
{
464+
$parser = new XliffParser($this->validXliffFile); // no target-language attribute
465+
$this->assertNull($parser->getTargetLanguage());
466+
}
467+
468+
public function testGetTargetLanguageXliff2ReturnsValueWhenSet(): void
469+
{
470+
$parser = new XliffParser($this->xliff2TargetFile); // trgLang="de"
471+
$this->assertSame('de', $parser->getTargetLanguage());
472+
}
473+
474+
public function testGetTargetLanguageXliff2ReturnsNullWhenNotSet(): void
475+
{
476+
$parser = new XliffParser($this->xliff2File); // no trgLang
477+
$this->assertNull($parser->getTargetLanguage());
478+
}
479+
480+
public function testGetTargetLanguageStripsRegionSuffixWithHyphen(): void
481+
{
482+
$file = $this->tempDir.'/region_hyphen.xlf';
483+
file_put_contents($file, <<<'EOT'
484+
<?xml version="1.0" encoding="utf-8"?>
485+
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
486+
<file source-language="en" target-language="de-AT" datatype="plaintext" original="region_hyphen.xlf">
487+
<body><trans-unit id="k"><source>x</source></trans-unit></body>
488+
</file>
489+
</xliff>
490+
EOT);
491+
$this->assertSame('de', (new XliffParser($file))->getTargetLanguage());
492+
}
493+
494+
public function testGetTargetLanguageStripsRegionSuffixWithUnderscore(): void
495+
{
496+
$file = $this->tempDir.'/region_underscore.xlf';
497+
file_put_contents($file, <<<'EOT'
498+
<?xml version="1.0" encoding="utf-8"?>
499+
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
500+
<file source-language="en" target-language="de_AT" datatype="plaintext" original="region_underscore.xlf">
501+
<body><trans-unit id="k"><source>x</source></trans-unit></body>
502+
</file>
503+
</xliff>
504+
EOT);
505+
$this->assertSame('de', (new XliffParser($file))->getTargetLanguage());
506+
}
507+
508+
public function testGetTargetLanguageXliff2StripsRegionSuffix(): void
509+
{
510+
$file = $this->tempDir.'/region_v2.xlf';
511+
file_put_contents($file, <<<'EOT'
512+
<?xml version="1.0" encoding="utf-8"?>
513+
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de-AT">
514+
<file id="messages">
515+
<unit id="k"><segment><source>x</source></segment></unit>
516+
</file>
517+
</xliff>
518+
EOT);
519+
$this->assertSame('de', (new XliffParser($file))->getTargetLanguage());
520+
}
521+
414522
public function testGetContentByKeyFallsBackToSourceWhenTargetIsEmptyXliff2(): void
415523
{
416524
$fallbackFile = $this->tempDir.'/v2_fallback.xlf';

0 commit comments

Comments
 (0)