diff --git a/README.md b/README.md
index 77cbf3b4..d5b03780 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,7 @@ If skipping rules, files, or folders is not sufficient, you can also skip entire
use a9f\FractorFluid\FluidFileProcessor;
use a9f\FractorHtaccess\HtaccessFileProcessor;
use a9f\FractorTypoScript\TypoScriptFileProcessor;
+use a9f\FractorYaml\XliffFileProcessor;
use a9f\FractorXml\XmlFileProcessor;
use a9f\FractorYaml\YamlFileProcessor;
@@ -137,6 +138,7 @@ return FractorConfiguration::configure()
FluidFileProcessor::class,
HtaccessFileProcessor::class,
TypoScriptFileProcessor::class,
+ XliffFileProcessor::class,
XmlFileProcessor::class,
YamlFileProcessor::class,
]);
diff --git a/composer.json b/composer.json
index 7f89d0c2..e270ff31 100644
--- a/composer.json
+++ b/composer.json
@@ -59,6 +59,7 @@
"a9f/fractor-phpstan-rules": "self.version",
"a9f/fractor-rule-generator": "self.version",
"a9f/fractor-typoscript": "self.version",
+ "a9f/fractor-xliff": "self.version",
"a9f/fractor-xml": "self.version",
"a9f/fractor-yaml": "self.version",
"a9f/typo3-fractor": "self.version"
@@ -78,6 +79,10 @@
"a9f\\FractorPhpStanRules\\": "packages/fractor-phpstan-rules/src/",
"a9f\\FractorRuleGenerator\\": "packages/fractor-rule-generator/src/",
"a9f\\FractorTypoScript\\": "packages/fractor-typoscript/src/",
+ "a9f\\FractorXliff\\": [
+ "packages/fractor-xliff/rules/",
+ "packages/fractor-xliff/src/"
+ ],
"a9f\\FractorXml\\": "packages/fractor-xml/src/",
"a9f\\FractorYaml\\": "packages/fractor-yaml/src/",
"a9f\\Fractor\\": "packages/fractor/src/",
@@ -99,6 +104,10 @@
"a9f\\FractorPhpStanRules\\Tests\\": "packages/fractor-phpstan-rules/tests/",
"a9f\\FractorRuleGenerator\\Tests\\": "packages/fractor-rule-generator/tests/",
"a9f\\FractorTypoScript\\Tests\\": "packages/fractor-typoscript/tests/",
+ "a9f\\FractorXliff\\Tests\\": [
+ "packages/fractor-xliff/rules-tests/",
+ "packages/fractor-xliff/tests/"
+ ],
"a9f\\FractorXml\\Tests\\": "packages/fractor-xml/tests/",
"a9f\\FractorYaml\\Tests\\": "packages/fractor-yaml/tests/",
"a9f\\Fractor\\Tests\\": "packages/fractor/tests/",
@@ -160,6 +169,7 @@
"@composer normalize --dry-run packages/fractor-fluid/composer.json",
"@composer normalize --dry-run packages/fractor-htaccess/composer.json",
"@composer normalize --dry-run packages/fractor-typoscript/composer.json",
+ "@composer normalize --dry-run packages/fractor-xliff/composer.json",
"@composer normalize --dry-run packages/fractor-xml/composer.json",
"@composer normalize --dry-run packages/fractor-yaml/composer.json",
"@composer normalize --dry-run packages/typo3-fractor/composer.json"
@@ -174,6 +184,7 @@
"@composer normalize --no-check-lock packages/fractor-fluid/composer.json",
"@composer normalize --no-check-lock packages/fractor-htaccess/composer.json",
"@composer normalize --no-check-lock packages/fractor-typoscript/composer.json",
+ "@composer normalize --no-check-lock packages/fractor-xliff/composer.json",
"@composer normalize --no-check-lock packages/fractor-xml/composer.json",
"@composer normalize --no-check-lock packages/fractor-yaml/composer.json",
"@composer normalize --no-check-lock packages/typo3-fractor/composer.json"
diff --git a/packages/fractor-xliff/.gitattributes b/packages/fractor-xliff/.gitattributes
new file mode 100644
index 00000000..27fe5f73
--- /dev/null
+++ b/packages/fractor-xliff/.gitattributes
@@ -0,0 +1,5 @@
+tests export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+phpunit.xml export-ignore
+README.md export-ignore
diff --git a/packages/fractor-xliff/.gitignore b/packages/fractor-xliff/.gitignore
new file mode 100644
index 00000000..ff1cad65
--- /dev/null
+++ b/packages/fractor-xliff/.gitignore
@@ -0,0 +1,3 @@
+/vendor/
+/composer.lock
+.phpunit.cache
diff --git a/packages/fractor-xliff/LICENSE b/packages/fractor-xliff/LICENSE
new file mode 100644
index 00000000..42f67d03
--- /dev/null
+++ b/packages/fractor-xliff/LICENSE
@@ -0,0 +1,25 @@
+The MIT License
+---------------
+
+Copyright (c) 2026-present Sebastian Schreiber and Christian Sonntag
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/fractor-xliff/README.md b/packages/fractor-xliff/README.md
new file mode 100644
index 00000000..ef73efaf
--- /dev/null
+++ b/packages/fractor-xliff/README.md
@@ -0,0 +1,91 @@
+# Fractor XLIFF
+
+XLIFF extension for the [Fractor](https://github.com/andreaswolf/fractor) file refactoring tool.
+
+Allows validating and transforming XLIFF (XML Localization Interchange File Format) translation files.
+Supports XLIFF Versions 1.0, 1.1, 1.2 and 2.0.
+
+## Installation
+
+```bash
+composer require a9f/fractor-xliff --dev
+```
+
+## Configuration
+
+```php
+withPaths([__DIR__ . '/Resources/Private/Language/'])
+ ->withOptions([
+ XliffProcessorOption::INDENT_CHARACTER => Indent::STYLE_SPACE,
+ XliffProcessorOption::INDENT_SIZE => 4,
+ XliffProcessorOption::ALLOWED_FILE_EXTENSIONS => ['xlf', 'xliff'],
+ ]);
+```
+
+Have a look at all available rules [Overview of all rules](docs/xliff-fractor-rules.md)
+
+## Processed File Extensions
+
+By default, the following file extensions are processed: `xlf`, `xliff`.
+
+## For Devlopers
+
+All rules must implement the a9f\FractorXliff\Contract\XliffFractorRule interface.
+The rule will be tagged with 'fractor.xliff_rule' and be injected in the XliffFileProcessor.
+
+### Testing with DDEV
+
+#### phpstan
+
+```bash
+ddev composer analyze:php
+```
+
+#### rector
+
+```bash
+ddev composer rector
+```
+
+Fix with:
+
+```bash
+ddev exec rector src/
+```
+
+#### composer normalize
+
+```bash
+ddev composer style:composer:normalize
+```
+
+Fix with:
+
+```bash
+ddev composer normalize
+```
+
+#### php-cs-fixer
+
+```bash
+ddev composer style:php:check
+```
+
+Fix with:
+
+```bash
+ddev exec ecs check --fix --config ecs.php src/
+```
+
+#### phpunit
+
+```bash
+ddev composer test:php
+```
diff --git a/packages/fractor-xliff/composer.json b/packages/fractor-xliff/composer.json
new file mode 100644
index 00000000..ce5b0aed
--- /dev/null
+++ b/packages/fractor-xliff/composer.json
@@ -0,0 +1,55 @@
+{
+ "name": "a9f/fractor-xliff",
+ "description": "XLIFF extension for the File Read-Analyse-Change Tool. Allows modifying XLIFF translation files",
+ "license": "MIT",
+ "type": "fractor-extension",
+ "keywords": [
+ "dev",
+ "fractor",
+ "upgrade",
+ "refactoring",
+ "automation",
+ "migration",
+ "xliff",
+ "translation"
+ ],
+ "require": {
+ "php": "^8.2",
+ "ext-dom": "*",
+ "ext-xml": "*",
+ "a9f/fractor": "^0.5.10",
+ "a9f/fractor-extension-installer": "^0.5.10",
+ "simonschaufi/pretty-xml": "^3.0.0",
+ "symplify/rule-doc-generator-contracts": "^11.2",
+ "webmozart/assert": "^1.11"
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "autoload": {
+ "psr-4": {
+ "a9f\\FractorXliff\\": [
+ "src/",
+ "rules/"
+ ]
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "a9f\\FractorXliff\\Tests\\": [
+ "tests/",
+ "rules-tests/"
+ ]
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "a9f/fractor-extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.5-dev"
+ }
+ }
+}
diff --git a/packages/fractor-xliff/config/application.php b/packages/fractor-xliff/config/application.php
new file mode 100644
index 00000000..07f37666
--- /dev/null
+++ b/packages/fractor-xliff/config/application.php
@@ -0,0 +1,38 @@
+services();
+ $services->defaults()
+ ->autowire()
+ ->autoconfigure();
+
+ $services->load('a9f\\FractorXliff\\', __DIR__ . '/../src/');
+
+ $services->set('fractor.xliff_processor.indent', Indent::class)
+ ->factory([service(IndentFactory::class), 'create']);
+
+ $services->set('fractor.xliff_processor.format_configuration', XliffFormatConfiguration::class)
+ ->factory([null, 'createFromParameterBag']);
+
+ $services->set(XliffFileProcessor::class)
+ ->arg('$indent', service('fractor.xliff_processor.indent'))
+ ->arg('$rules', tagged_iterator('fractor.xliff_rule'))
+ ->arg('$xliffFormatConfiguration', service('fractor.xliff_processor.format_configuration'));
+
+ $services->set(Formatter::class);
+
+ $containerBuilder->registerForAutoconfiguration(XliffFractorRule::class)->addTag('fractor.xliff_rule');
+};
diff --git a/packages/fractor-xliff/docs/xliff-fractor-rules.md b/packages/fractor-xliff/docs/xliff-fractor-rules.md
new file mode 100644
index 00000000..c0fcbc7c
--- /dev/null
+++ b/packages/fractor-xliff/docs/xliff-fractor-rules.md
@@ -0,0 +1,75 @@
+# 3 Rules Overview
+
+## ConvertXliff1To2Fractor
+
+Convert XLIFF 1.2 files to XLIFF 2.0 format
+
+- class: [`a9f\FractorXliff\ConvertXliff1To2Fractor`](../rules/ConvertXliff1To2Fractor.php)
+
+```diff
+
+-
+-
+-
+-
+-
++
++
++
++
+ Hello
+-
+-
++
++
+
+
+```
+
+
+
+## EnsureXliffHasSourceLanguageFractor
+
+Ensure XLIFF files have the required source-language (v1.x) or srcLang (v2.0) attribute
+
+- class: [`a9f\FractorXliff\EnsureXliffHasSourceLanguageFractor`](../rules/EnsureXliffHasSourceLanguageFractor.php)
+
+```diff
+
+
+-
++
+
+
+ Hello
+
+
+
+
+```
+
+
+
+## EnsureXliffHasTargetLanguageFractor
+
+Add target-language attribute to localized XLIFF files where the filename starts with a 2-letter ISO language code
+
+- class: [`a9f\FractorXliff\EnsureXliffHasTargetLanguageFractor`](../rules/EnsureXliffHasTargetLanguageFractor.php)
+
+```diff
+
+
+
+-
++
+
+
+ Hello
+ Hallo
+
+
+
+
+```
+
+
diff --git a/packages/fractor-xliff/phpunit.xml b/packages/fractor-xliff/phpunit.xml
new file mode 100644
index 00000000..bdfed53f
--- /dev/null
+++ b/packages/fractor-xliff/phpunit.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ tests
+
+
+
+
+ ./src
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/ConvertXliff1To2FractorTest.php b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/ConvertXliff1To2FractorTest.php
new file mode 100644
index 00000000..113409b7
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/ConvertXliff1To2FractorTest.php
@@ -0,0 +1,29 @@
+doTestFile($filePath);
+ $this->assertThatRuleIsApplied(ConvertXliff1To2Fractor::class);
+ }
+
+ public static function provideData(): \Iterator
+ {
+ return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures', '*.xlf.fixture');
+ }
+
+ public function provideConfigFilePath(): string
+ {
+ return __DIR__ . '/config/fractor.php';
+ }
+}
diff --git a/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/basic.xlf.fixture b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/basic.xlf.fixture
new file mode 100644
index 00000000..b9560f66
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/basic.xlf.fixture
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ Hello
+
+
+
+
+-----
+
+
+
+
+
+ Hello
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-groups.xlf.fixture b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-groups.xlf.fixture
new file mode 100644
index 00000000..371272fd
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-groups.xlf.fixture
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+-----
+
+
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-notes.xlf.fixture b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-notes.xlf.fixture
new file mode 100644
index 00000000..4f343553
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-notes.xlf.fixture
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Hello
+ This is a greeting
+
+
+
+
+-----
+
+
+
+
+
+ This is a greeting
+
+
+ Hello
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-target-and-approved.xlf.fixture b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-target-and-approved.xlf.fixture
new file mode 100644
index 00000000..9f59c443
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/Fixtures/with-target-and-approved.xlf.fixture
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ Hello
+ Hallo
+
+
+ World
+ Welt
+
+
+
+
+-----
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
+ World
+ Welt
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/config/fractor.php b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/config/fractor.php
new file mode 100644
index 00000000..75a642da
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/ConvertXliff1To2Fractor/config/fractor.php
@@ -0,0 +1,15 @@
+withOptions([
+ XliffProcessorOption::INDENT_CHARACTER => Indent::STYLE_TAB,
+ XliffProcessorOption::INDENT_SIZE => 1,
+ ])
+ ->withRules([ConvertXliff1To2Fractor::class]);
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/EnsureXliffHasSourceLanguageFractorTest.php b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/EnsureXliffHasSourceLanguageFractorTest.php
new file mode 100644
index 00000000..8d213d2c
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/EnsureXliffHasSourceLanguageFractorTest.php
@@ -0,0 +1,29 @@
+doTestFile($filePath);
+ $this->assertThatRuleIsApplied(EnsureXliffHasSourceLanguageFractor::class);
+ }
+
+ public static function provideData(): \Iterator
+ {
+ return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures', '*.xlf.fixture');
+ }
+
+ public function provideConfigFilePath(): string
+ {
+ return __DIR__ . '/config/fractor.php';
+ }
+}
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/Fixtures/missing-source-language-v1.xlf.fixture b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/Fixtures/missing-source-language-v1.xlf.fixture
new file mode 100644
index 00000000..1a2c0731
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/Fixtures/missing-source-language-v1.xlf.fixture
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Hello
+
+
+
+
+-----
+
+
+
+
+
+ Hello
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/Fixtures/missing-srclang-v2.xlf.fixture b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/Fixtures/missing-srclang-v2.xlf.fixture
new file mode 100644
index 00000000..987536c9
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/Fixtures/missing-srclang-v2.xlf.fixture
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Hello
+
+
+
+
+-----
+
+
+
+
+
+ Hello
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/config/fractor.php b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/config/fractor.php
new file mode 100644
index 00000000..390e49bc
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasSourceLanguageFractor/config/fractor.php
@@ -0,0 +1,15 @@
+withOptions([
+ XliffProcessorOption::INDENT_CHARACTER => Indent::STYLE_TAB,
+ XliffProcessorOption::INDENT_SIZE => 1,
+ ])
+ ->withRules([EnsureXliffHasSourceLanguageFractor::class]);
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/EnsureXliffHasTargetLanguageFractorTest.php b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/EnsureXliffHasTargetLanguageFractorTest.php
new file mode 100644
index 00000000..c29ed6bb
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/EnsureXliffHasTargetLanguageFractorTest.php
@@ -0,0 +1,29 @@
+doTestFile($filePath);
+ $this->assertThatRuleIsApplied(EnsureXliffHasTargetLanguageFractor::class);
+ }
+
+ public static function provideData(): \Iterator
+ {
+ return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures', '*.xlf.fixture');
+ }
+
+ public function provideConfigFilePath(): string
+ {
+ return __DIR__ . '/config/fractor.php';
+ }
+}
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/Fixtures/de.add-target-lang-v1.xlf.fixture b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/Fixtures/de.add-target-lang-v1.xlf.fixture
new file mode 100644
index 00000000..71123bd1
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/Fixtures/de.add-target-lang-v1.xlf.fixture
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
+-----
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/Fixtures/de.add-trglang-v2.xlf.fixture b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/Fixtures/de.add-trglang-v2.xlf.fixture
new file mode 100644
index 00000000..2c858437
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/Fixtures/de.add-trglang-v2.xlf.fixture
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
+-----
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
diff --git a/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/config/fractor.php b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/config/fractor.php
new file mode 100644
index 00000000..c3d11404
--- /dev/null
+++ b/packages/fractor-xliff/rules-tests/EnsureXliffHasTargetLanguageFractor/config/fractor.php
@@ -0,0 +1,15 @@
+withOptions([
+ XliffProcessorOption::INDENT_CHARACTER => Indent::STYLE_TAB,
+ XliffProcessorOption::INDENT_SIZE => 1,
+ ])
+ ->withRules([EnsureXliffHasTargetLanguageFractor::class]);
diff --git a/packages/fractor-xliff/rules/ConvertXliff1To2Fractor.php b/packages/fractor-xliff/rules/ConvertXliff1To2Fractor.php
new file mode 100644
index 00000000..93323df4
--- /dev/null
+++ b/packages/fractor-xliff/rules/ConvertXliff1To2Fractor.php
@@ -0,0 +1,223 @@
+version === XliffVersion::V2_0) {
+ return null;
+ }
+
+ $oldDoc = $xliffDocument->document;
+ $newDoc = new \DOMDocument('1.0', 'UTF-8');
+ $newDoc->formatOutput = true;
+
+ $oldRoot = $oldDoc->documentElement;
+ if (! $oldRoot instanceof \DOMElement) {
+ return null;
+ }
+
+ $newRoot = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'xliff');
+ $newRoot->setAttribute('version', '2.0');
+ $newDoc->appendChild($newRoot);
+
+ $this->convertFiles($oldRoot, $newDoc, $newRoot);
+
+ return new XliffDocument($newDoc, XliffVersion::V2_0, $xliffDocument->file);
+ }
+
+ public function getRuleDefinition(): RuleDefinition
+ {
+ return new RuleDefinition('Convert XLIFF 1.2 files to XLIFF 2.0 format', [new CodeSample(
+ <<<'CODE_SAMPLE'
+
+
+
+
+
+
+ Hello
+
+
+
+
+CODE_SAMPLE
+ ,
+ <<<'CODE_SAMPLE'
+
+
+
+
+
+ Hello
+
+
+
+
+CODE_SAMPLE
+ )]);
+ }
+
+ private function convertFiles(\DOMElement $oldRoot, \DOMDocument $newDoc, \DOMElement $newRoot): void
+ {
+ foreach ($this->getChildElementsByTagName($oldRoot, 'file') as $oldFile) {
+ $sourceLang = $oldFile->getAttribute('source-language');
+ $targetLang = $oldFile->getAttribute('target-language');
+
+ if ($sourceLang !== '') {
+ $newRoot->setAttribute('srcLang', $sourceLang);
+ }
+
+ if ($targetLang !== '') {
+ $newRoot->setAttribute('trgLang', $targetLang);
+ }
+
+ $fileId = $oldFile->getAttribute('original');
+ if ($fileId === '') {
+ $fileId = 'f1';
+ }
+
+ $newFile = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'file');
+ $newFile->setAttribute('id', $fileId);
+ $newRoot->appendChild($newFile);
+
+ $body = $this->getFirstChildElementByTagName($oldFile, 'body');
+ if ($body instanceof \DOMElement) {
+ $this->convertChildren($body, $newDoc, $newFile);
+ }
+ }
+ }
+
+ private function convertChildren(\DOMElement $parent, \DOMDocument $newDoc, \DOMElement $newParent): void
+ {
+ foreach ($parent->childNodes as $child) {
+ if (! $child instanceof \DOMElement) {
+ continue;
+ }
+
+ $localName = $child->localName ?? '';
+ if ($localName === 'trans-unit') {
+ $this->convertTransUnit($child, $newDoc, $newParent);
+ } elseif ($localName === 'group') {
+ $this->convertGroup($child, $newDoc, $newParent);
+ }
+ }
+ }
+
+ private function convertTransUnit(\DOMElement $transUnit, \DOMDocument $newDoc, \DOMElement $newParent): void
+ {
+ $unit = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'unit');
+ $id = $transUnit->getAttribute('id');
+ if ($id !== '') {
+ $unit->setAttribute('id', $id);
+ }
+
+ $newParent->appendChild($unit);
+
+ $this->convertNotes($transUnit, $newDoc, $unit);
+
+ $segment = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'segment');
+
+ $approved = $transUnit->getAttribute('approved');
+ if ($approved === 'yes') {
+ $segment->setAttribute('state', 'final');
+ } elseif ($approved === 'no') {
+ $segment->setAttribute('state', 'translated');
+ }
+
+ $source = $this->getFirstChildElementByTagName($transUnit, 'source');
+ if ($source instanceof \DOMElement) {
+ $newSource = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'source');
+ $this->copyInnerContent($source, $newDoc, $newSource);
+ $segment->appendChild($newSource);
+ }
+
+ $target = $this->getFirstChildElementByTagName($transUnit, 'target');
+ if ($target instanceof \DOMElement) {
+ $newTarget = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'target');
+ $this->copyInnerContent($target, $newDoc, $newTarget);
+ $segment->appendChild($newTarget);
+ }
+
+ $unit->appendChild($segment);
+ }
+
+ private function convertGroup(\DOMElement $oldGroup, \DOMDocument $newDoc, \DOMElement $newParent): void
+ {
+ $newGroup = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'group');
+ $id = $oldGroup->getAttribute('id');
+ if ($id !== '') {
+ $newGroup->setAttribute('id', $id);
+ }
+
+ $newParent->appendChild($newGroup);
+ $this->convertChildren($oldGroup, $newDoc, $newGroup);
+ }
+
+ private function convertNotes(\DOMElement $transUnit, \DOMDocument $newDoc, \DOMElement $unit): void
+ {
+ $noteElements = $this->getChildElementsByTagName($transUnit, 'note');
+ if ($noteElements === []) {
+ return;
+ }
+
+ $notesContainer = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'notes');
+ foreach ($noteElements as $oldNote) {
+ $newNote = $newDoc->createElementNS(self::XLIFF_2_NAMESPACE, 'note');
+ $this->copyInnerContent($oldNote, $newDoc, $newNote);
+ $notesContainer->appendChild($newNote);
+ }
+
+ $unit->appendChild($notesContainer);
+ }
+
+ private function copyInnerContent(\DOMElement $source, \DOMDocument $newDoc, \DOMElement $target): void
+ {
+ foreach ($source->childNodes as $child) {
+ $imported = $newDoc->importNode($child, true);
+ $target->appendChild($imported);
+ }
+ }
+
+ /**
+ * @return list<\DOMElement>
+ */
+ private function getChildElementsByTagName(\DOMElement $parent, string $tagName): array
+ {
+ $elements = [];
+ foreach ($parent->childNodes as $child) {
+ if ($child instanceof \DOMElement && ($child->localName ?? '') === $tagName) {
+ $elements[] = $child;
+ }
+ }
+
+ return $elements;
+ }
+
+ private function getFirstChildElementByTagName(\DOMElement $parent, string $tagName): ?\DOMElement
+ {
+ foreach ($parent->childNodes as $child) {
+ if ($child instanceof \DOMElement && ($child->localName ?? '') === $tagName) {
+ return $child;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/packages/fractor-xliff/rules/EnsureXliffHasSourceLanguageFractor.php b/packages/fractor-xliff/rules/EnsureXliffHasSourceLanguageFractor.php
new file mode 100644
index 00000000..5b35e32b
--- /dev/null
+++ b/packages/fractor-xliff/rules/EnsureXliffHasSourceLanguageFractor.php
@@ -0,0 +1,103 @@
+version === XliffVersion::V2_0) {
+ return $this->ensureV2SourceLanguage($xliffDocument);
+ }
+
+ return $this->ensureV1SourceLanguage($xliffDocument);
+ }
+
+ public function getRuleDefinition(): RuleDefinition
+ {
+ return new RuleDefinition(
+ 'Ensure XLIFF files have the required source-language (v1.x) or srcLang (v2.0) attribute',
+ [new CodeSample(
+ <<<'CODE_SAMPLE'
+
+
+
+
+
+ Hello
+
+
+
+
+CODE_SAMPLE
+ ,
+ <<<'CODE_SAMPLE'
+
+
+
+
+
+ Hello
+
+
+
+
+CODE_SAMPLE
+ )]
+ );
+ }
+
+ private function ensureV1SourceLanguage(XliffDocument $xliffDocument): ?XliffDocument
+ {
+ $changed = false;
+ $rootElement = $xliffDocument->document->documentElement;
+ if (! $rootElement instanceof \DOMElement) {
+ return null;
+ }
+
+ foreach ($rootElement->childNodes as $child) {
+ if (! $child instanceof \DOMElement) {
+ continue;
+ }
+ if (($child->localName ?? '') !== 'file') {
+ continue;
+ }
+ if ($child->getAttribute('source-language') === '') {
+ $child->setAttribute('source-language', self::DEFAULT_SOURCE_LANGUAGE);
+ $changed = true;
+ }
+ }
+
+ return $changed ? $xliffDocument : null;
+ }
+
+ private function ensureV2SourceLanguage(XliffDocument $xliffDocument): ?XliffDocument
+ {
+ $rootElement = $xliffDocument->document->documentElement;
+ if (! $rootElement instanceof \DOMElement) {
+ return null;
+ }
+
+ if ($rootElement->getAttribute('srcLang') !== '') {
+ return null;
+ }
+
+ $rootElement->setAttribute('srcLang', self::DEFAULT_SOURCE_LANGUAGE);
+
+ return $xliffDocument;
+ }
+}
diff --git a/packages/fractor-xliff/rules/EnsureXliffHasTargetLanguageFractor.php b/packages/fractor-xliff/rules/EnsureXliffHasTargetLanguageFractor.php
new file mode 100644
index 00000000..a5365739
--- /dev/null
+++ b/packages/fractor-xliff/rules/EnsureXliffHasTargetLanguageFractor.php
@@ -0,0 +1,121 @@
+extractLanguageFromFilename($xliffDocument->file->getFileName());
+ if ($language === null) {
+ return null;
+ }
+
+ if ($xliffDocument->version === XliffVersion::V2_0) {
+ return $this->addV2TargetLanguage($xliffDocument, $language);
+ }
+
+ return $this->addV1TargetLanguage($xliffDocument, $language);
+ }
+
+ public function getRuleDefinition(): RuleDefinition
+ {
+ return new RuleDefinition(
+ 'Add target-language attribute to localized XLIFF files where the filename starts with a 2-letter ISO language code',
+ [new CodeSample(
+ <<<'CODE_SAMPLE'
+
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
+CODE_SAMPLE
+ ,
+ <<<'CODE_SAMPLE'
+
+
+
+
+
+
+ Hello
+ Hallo
+
+
+
+
+CODE_SAMPLE
+ )]
+ );
+ }
+
+ private function addV1TargetLanguage(XliffDocument $xliffDocument, string $language): ?XliffDocument
+ {
+ $changed = false;
+ $rootElement = $xliffDocument->document->documentElement;
+ if (! $rootElement instanceof \DOMElement) {
+ return null;
+ }
+
+ foreach ($rootElement->childNodes as $child) {
+ if (! $child instanceof \DOMElement) {
+ continue;
+ }
+ if (($child->localName ?? '') !== 'file') {
+ continue;
+ }
+ if ($child->getAttribute('target-language') !== '') {
+ continue;
+ }
+
+ $child->setAttribute('target-language', $language);
+ $changed = true;
+ }
+
+ return $changed ? $xliffDocument : null;
+ }
+
+ private function addV2TargetLanguage(XliffDocument $xliffDocument, string $language): ?XliffDocument
+ {
+ $rootElement = $xliffDocument->document->documentElement;
+ if (! $rootElement instanceof \DOMElement) {
+ return null;
+ }
+
+ if ($rootElement->getAttribute('trgLang') !== '') {
+ return null;
+ }
+
+ $rootElement->setAttribute('trgLang', $language);
+
+ return $xliffDocument;
+ }
+
+ private function extractLanguageFromFilename(string $filename): ?string
+ {
+ if (\preg_match('/^([a-z]{2})\./i', $filename, $matches) !== 1) {
+ return null;
+ }
+
+ return \strtolower($matches[1]);
+ }
+}
diff --git a/packages/fractor-xliff/src/Configuration/XliffProcessorOption.php b/packages/fractor-xliff/src/Configuration/XliffProcessorOption.php
new file mode 100644
index 00000000..6e7c3f28
--- /dev/null
+++ b/packages/fractor-xliff/src/Configuration/XliffProcessorOption.php
@@ -0,0 +1,14 @@
+preserveWhiteSpace = false;
+ $document->formatOutput = true;
+
+ return $document;
+ }
+}
diff --git a/packages/fractor-xliff/src/IndentFactory.php b/packages/fractor-xliff/src/IndentFactory.php
new file mode 100644
index 00000000..e6848175
--- /dev/null
+++ b/packages/fractor-xliff/src/IndentFactory.php
@@ -0,0 +1,29 @@
+parameterBag->has(XliffProcessorOption::INDENT_SIZE) ? $this->parameterBag->get(
+ XliffProcessorOption::INDENT_SIZE
+ ) : 4;
+ $style = $this->parameterBag->has(XliffProcessorOption::INDENT_CHARACTER) ? $this->parameterBag->get(
+ XliffProcessorOption::INDENT_CHARACTER
+ ) : Indent::STYLE_SPACE;
+
+ return Indent::fromSizeAndStyle($size, $style);
+ }
+}
diff --git a/packages/fractor-xliff/src/ValueObject/XliffDocument.php b/packages/fractor-xliff/src/ValueObject/XliffDocument.php
new file mode 100644
index 00000000..fee76883
--- /dev/null
+++ b/packages/fractor-xliff/src/ValueObject/XliffDocument.php
@@ -0,0 +1,17 @@
+ $allowedFileExtensions
+ */
+ public function __construct(
+ public array $allowedFileExtensions
+ ) {
+ }
+
+ public static function createFromParameterBag(ParameterBagInterface $parameterBag): self
+ {
+ /** @var list $allowedFileExtensions */
+ $allowedFileExtensions = $parameterBag->has(XliffProcessorOption::ALLOWED_FILE_EXTENSIONS)
+ ? $parameterBag->get(XliffProcessorOption::ALLOWED_FILE_EXTENSIONS)
+ : ['xlf', 'xliff'];
+
+ return new self($allowedFileExtensions);
+ }
+}
diff --git a/packages/fractor-xliff/src/ValueObject/XliffVersion.php b/packages/fractor-xliff/src/ValueObject/XliffVersion.php
new file mode 100644
index 00000000..daf72138
--- /dev/null
+++ b/packages/fractor-xliff/src/ValueObject/XliffVersion.php
@@ -0,0 +1,51 @@
+ '1.1',
+ 'urn:oasis:names:tc:xliff:document:1.2' => '1.2',
+ 'urn:oasis:names:tc:xliff:document:2.0' => '2.0',
+ ];
+
+ public static function fromDomDocument(\DOMDocument $document): self
+ {
+ $rootElement = $document->documentElement;
+ Assert::notNull($rootElement, 'XLIFF document has no root element');
+
+ $localName = $rootElement->localName ?? '';
+ Assert::same(
+ \strtolower($localName),
+ 'xliff',
+ \sprintf('Expected root element "xliff", got "%s"', $localName)
+ );
+
+ $version = $rootElement->getAttribute('version');
+ if ($version !== '') {
+ $matched = self::tryFrom($version);
+ if ($matched !== null) {
+ return $matched;
+ }
+ }
+
+ $namespaceUri = $rootElement->namespaceURI ?? '';
+ if (isset(self::NAMESPACE_MAP[$namespaceUri])) {
+ return self::from(self::NAMESPACE_MAP[$namespaceUri]);
+ }
+
+ throw new \InvalidArgumentException(
+ 'Could not determine XLIFF version: no version attribute or known namespace found'
+ );
+ }
+}
diff --git a/packages/fractor-xliff/src/XliffFileProcessor.php b/packages/fractor-xliff/src/XliffFileProcessor.php
new file mode 100644
index 00000000..d1144022
--- /dev/null
+++ b/packages/fractor-xliff/src/XliffFileProcessor.php
@@ -0,0 +1,112 @@
+
+ */
+final readonly class XliffFileProcessor implements FileProcessor
+{
+ /**
+ * @param iterable $rules
+ */
+ public function __construct(
+ private DomDocumentFactory $domDocumentFactory,
+ private Formatter $formatter,
+ private iterable $rules,
+ private Indent $indent,
+ private ChangedFilesDetector $changedFilesDetector,
+ private XliffFormatConfiguration $xliffFormatConfiguration
+ ) {
+ }
+
+ public function canHandle(File $file): bool
+ {
+ return in_array($file->getFileExtension(), $this->allowedFileExtensions(), true);
+ }
+
+ /**
+ * @param iterable $appliedRules
+ */
+ public function handle(File $file, iterable $appliedRules): void
+ {
+ $document = $this->domDocumentFactory->create();
+ $originalXml = $file->getOriginalContent();
+ $document->loadXML($originalXml);
+
+ // Normalize baseline formatting for clean diffs
+ $oldXml = $this->saveXml($document);
+ $oldXml = $this->formatXml($oldXml);
+ $file->changeOriginalContent($oldXml);
+
+ $version = XliffVersion::fromDomDocument($document);
+ $xliffDocument = new XliffDocument($document, $version, $file);
+
+ foreach ($appliedRules as $rule) {
+ $result = $rule->refactor($xliffDocument);
+ if ($result !== null) {
+ $xliffDocument = $result;
+ $file->addAppliedRule(AppliedRule::fromRule($rule));
+ }
+ }
+
+ $newXml = $this->saveXml($xliffDocument->document);
+ $newXml = $this->formatXml($newXml);
+
+ // Compare against raw original to detect formatting changes too
+ if ($newXml === $originalXml) {
+ return;
+ }
+
+ $file->changeFileContent($newXml);
+ if (! $file->hasChanged()) {
+ $this->changedFilesDetector->addCachableFile($file->getFilePath());
+ }
+ }
+
+ /**
+ * @return list
+ */
+ public function allowedFileExtensions(): array
+ {
+ return $this->xliffFormatConfiguration->allowedFileExtensions;
+ }
+
+ public function getAllRules(): iterable
+ {
+ return $this->rules;
+ }
+
+ private function saveXml(\DOMDocument $document): string
+ {
+ $xml = $document->saveXML();
+ if ($xml === false) {
+ throw new ShouldNotHappenException('Could not save XLIFF document');
+ }
+
+ return $xml;
+ }
+
+ private function formatXml(string $xml): string
+ {
+ $indentCharacter = $this->indent->isSpace() ? Indent::CHARACTERS[Indent::STYLE_SPACE] : Indent::CHARACTERS[Indent::STYLE_TAB];
+ $this->formatter->setIndentCharacter($indentCharacter);
+ $this->formatter->setIndentSize($this->indent->length());
+
+ return rtrim($this->formatter->format($xml)) . "\n";
+ }
+}
diff --git a/packages/fractor-xliff/tests/Fixtures/DummyXliffFractorRule.php b/packages/fractor-xliff/tests/Fixtures/DummyXliffFractorRule.php
new file mode 100644
index 00000000..bd79f7e1
--- /dev/null
+++ b/packages/fractor-xliff/tests/Fixtures/DummyXliffFractorRule.php
@@ -0,0 +1,33 @@
+document->getElementsByTagName('source');
+
+ foreach ($sourceElements as $sourceElement) {
+ if ($sourceElement->textContent === 'Hello') {
+ $sourceElement->textContent = 'Hello World';
+ $changed = true;
+ }
+ }
+
+ return $changed ? $xliffDocument : null;
+ }
+
+ public function getRuleDefinition(): RuleDefinition
+ {
+ throw new BadMethodCallException('Not implemented yet');
+ }
+}
diff --git a/packages/fractor-xliff/tests/XliffFileProcessor/Fixtures/xliff12.xlf.fixture b/packages/fractor-xliff/tests/XliffFileProcessor/Fixtures/xliff12.xlf.fixture
new file mode 100644
index 00000000..b683725a
--- /dev/null
+++ b/packages/fractor-xliff/tests/XliffFileProcessor/Fixtures/xliff12.xlf.fixture
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Hello
+
+
+
+
+-----
+
+
+
+
+
+ Hello World
+
+
+
+
diff --git a/packages/fractor-xliff/tests/XliffFileProcessor/XliffFileProcessorTest.php b/packages/fractor-xliff/tests/XliffFileProcessor/XliffFileProcessorTest.php
new file mode 100644
index 00000000..ba4f01d7
--- /dev/null
+++ b/packages/fractor-xliff/tests/XliffFileProcessor/XliffFileProcessorTest.php
@@ -0,0 +1,29 @@
+doTestFile($filePath);
+ $this->assertThatRuleIsApplied(DummyXliffFractorRule::class);
+ }
+
+ public static function provideData(): \Iterator
+ {
+ return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures', '*.xlf.fixture');
+ }
+
+ public function provideConfigFilePath(): string
+ {
+ return __DIR__ . '/config/fractor.php';
+ }
+}
diff --git a/packages/fractor-xliff/tests/XliffFileProcessor/config/fractor.php b/packages/fractor-xliff/tests/XliffFileProcessor/config/fractor.php
new file mode 100644
index 00000000..439dd186
--- /dev/null
+++ b/packages/fractor-xliff/tests/XliffFileProcessor/config/fractor.php
@@ -0,0 +1,15 @@
+withOptions([
+ XliffProcessorOption::INDENT_CHARACTER => Indent::STYLE_TAB,
+ XliffProcessorOption::INDENT_SIZE => 1,
+ ])
+ ->withRules([DummyXliffFractorRule::class]);
diff --git a/packages/fractor/tests/Application/ProcessorSkipper/ProcessorSkipperTest.php b/packages/fractor/tests/Application/ProcessorSkipper/ProcessorSkipperTest.php
index 643680ee..e5b9cf2b 100644
--- a/packages/fractor/tests/Application/ProcessorSkipper/ProcessorSkipperTest.php
+++ b/packages/fractor/tests/Application/ProcessorSkipper/ProcessorSkipperTest.php
@@ -9,6 +9,7 @@
use a9f\FractorFluid\FluidFileProcessor;
use a9f\FractorHtaccess\HtaccessFileProcessor;
use a9f\FractorTypoScript\TypoScriptFileProcessor;
+use a9f\FractorXliff\XliffFileProcessor;
use a9f\FractorXml\XmlFileProcessor;
use a9f\FractorYaml\YamlFileProcessor;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -78,6 +79,7 @@ public function multipleProcessorsCanBeSkippedSimultaneously(): void
self::assertTrue($subject->shouldSkip(FluidFileProcessor::class));
self::assertFalse($subject->shouldSkip(HtaccessFileProcessor::class));
self::assertFalse($subject->shouldSkip(TypoScriptFileProcessor::class));
+ self::assertFalse($subject->shouldSkip(XliffFileProcessor::class));
self::assertTrue($subject->shouldSkip(XmlFileProcessor::class));
self::assertTrue($subject->shouldSkip(YamlFileProcessor::class));
}
@@ -102,6 +104,7 @@ private static function allProcessorClasses(): array
FluidFileProcessor::class,
HtaccessFileProcessor::class,
TypoScriptFileProcessor::class,
+ XliffFileProcessor::class,
XmlFileProcessor::class,
YamlFileProcessor::class,
];
diff --git a/packages/typo3-fractor/composer.json b/packages/typo3-fractor/composer.json
index 25fb6d01..7099251a 100644
--- a/packages/typo3-fractor/composer.json
+++ b/packages/typo3-fractor/composer.json
@@ -27,6 +27,7 @@
"a9f/fractor-fluid": "^0.5.10",
"a9f/fractor-htaccess": "^0.5.10",
"a9f/fractor-typoscript": "^0.5.10",
+ "a9f/fractor-xliff": "^0.5.10",
"a9f/fractor-xml": "^0.5.10",
"a9f/fractor-yaml": "^0.5.10",
"symplify/rule-doc-generator-contracts": "^11.2"