From a6c9b0711928603f328e4b23ac86dbb2e983f260 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Wed, 15 Jul 2026 11:45:55 +0200 Subject: [PATCH 1/6] IBX-12091: Added TypeScript translation extraction support --- composer.json | 1 + package.json | 3 +- .../config/services/translation.yaml | 4 + .../Extractor/TypeScriptFileVisitor.php | 267 +++++++++++++++++ .../typescript_translation_extractor.mjs | 176 +++++++++++ .../Extractor/TypeScriptFileVisitorTest.php | 283 ++++++++++++++++++ ...peScriptTranslationExtractorScriptTest.php | 235 +++++++++++++++ 7 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 src/lib/Translation/Extractor/TypeScriptFileVisitor.php create mode 100644 src/lib/Translation/Extractor/typescript_translation_extractor.mjs create mode 100644 tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php create mode 100644 tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php diff --git a/composer.json b/composer.json index 97beea181e..aabe7b196c 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "symfony/http-foundation": "^7.4", "symfony/http-kernel": "^7.4", "symfony/options-resolver": "^7.4", + "symfony/process": "^7.4", "symfony/routing": "^7.4", "symfony/security-core": "^7.4", "symfony/security-http": "^7.4", diff --git a/package.json b/package.json index bd45552855..820d1c3d1d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "@ibexa/eslint-config": "https://github.com/ibexa/eslint-config-ibexa.git#~v2.0.0", "@ibexa/ts-config": "https://github.com/ibexa/ts-config-ibexa#~v1.1.0", "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2" + "@types/react-dom": "^19.1.2", + "@typescript-eslint/typescript-estree": "^8.62.1" }, "scripts": { "test": "yarn prettier-test && yarn ts-test && yarn eslint-test", diff --git a/src/bundle/Resources/config/services/translation.yaml b/src/bundle/Resources/config/services/translation.yaml index 33b908d882..2a9ea58fc9 100644 --- a/src/bundle/Resources/config/services/translation.yaml +++ b/src/bundle/Resources/config/services/translation.yaml @@ -8,6 +8,10 @@ services: tags: - { name: jms_translation.file_visitor } + Ibexa\AdminUi\Translation\Extractor\TypeScriptFileVisitor: + tags: + - { name: jms_translation.file_visitor } + Ibexa\AdminUi\Translation\Extractor\SortingTranslationExtractor: tags: - { name: jms_translation.extractor, alias: ez_location_sorting } diff --git a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php new file mode 100644 index 0000000000..4d8a814427 --- /dev/null +++ b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php @@ -0,0 +1,267 @@ +logger = new NullLogger(); + $this->parserScriptPath = $parserScriptPath ?? __DIR__ . '/typescript_translation_extractor.mjs'; + } + + public function __destruct() + { + $this->discardServerProcess(); + } + + public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void + { + if (!$this->supports($file)) { + return; + } + + $this->assertRuntimeIsReady(); + + $decoded = $this->requestExtraction((string) $file->getRealPath()); + + if (isset($decoded['error'])) { + $this->logger?->error(sprintf( + 'Unable to parse TypeScript file %s: %s', + $file->getRealPath(), + $decoded['error'], + )); + + return; + } + + if (!isset($decoded['messages']) || !is_array($decoded['messages'])) { + $this->logger?->error(sprintf( + 'Unable to decode TypeScript extractor output for file %s.', + $file->getRealPath(), + )); + + return; + } + + foreach ($decoded['warnings'] ?? [] as $warning) { + if (is_string($warning)) { + $this->logger?->error($warning); + } + } + + foreach ($decoded['messages'] as $messageData) { + if (!is_array($messageData) || !isset($messageData['id']) || !is_string($messageData['id'])) { + continue; + } + + $message = new Message( + $messageData['id'], + isset($messageData['domain']) && is_string($messageData['domain']) ? $messageData['domain'] : $this->defaultDomain, + ); + + if (isset($messageData['desc']) && is_string($messageData['desc'])) { + $message->setDesc($messageData['desc']); + } + + $message->addSource(new FileSource((string) $file)); + $catalogue->add($message); + } + } + + /** + * @param array $ast + */ + public function visitPhpFile(SplFileInfo $file, MessageCatalogue $catalogue, array $ast): void + { + } + + public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast): void + { + } + + private function supports(SplFileInfo $file): bool + { + $path = $file->getRealPath(); + + return ($path !== false) + && (str_ends_with($path, '.ts') || str_ends_with($path, '.tsx')) + && !str_ends_with($path, '.d.ts'); + } + + private function assertRuntimeIsReady(): void + { + if ($this->runtimeReady === true) { + return; + } + + if (!is_file($this->parserScriptPath)) { + throw new RuntimeException(sprintf( + 'TypeScript translation extractor script not found: %s.', + $this->parserScriptPath, + )); + } + + $process = new Process([ + $this->nodeBinary, + $this->parserScriptPath, + '--check-runtime', + ]); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf( + 'TypeScript translation extractor runtime is not available. Please ensure `%s` is installed and `@typescript-eslint/typescript-estree` is available for the admin-ui package. %s', + $this->nodeBinary, + trim($process->getErrorOutput()), + )); + } + + $this->runtimeReady = true; + } + + /** + * @return array{messages?: mixed, warnings?: mixed, error?: string} + */ + private function requestExtraction(string $filePath): array + { + $this->ensureServerStarted(); + + $this->getServerInput()->write($filePath . "\n"); + + return $this->readServerLine(); + } + + private function ensureServerStarted(): void + { + if ($this->serverProcess !== null && $this->serverProcess->isRunning()) { + return; + } + + $input = new InputStream(); + + $process = new Process([ + $this->nodeBinary, + $this->parserScriptPath, + '--serve', + ]); + $process->setInput($input); + $process->setTimeout(null); + $process->start(); + + $this->serverInput = $input; + $this->serverProcess = $process; + $this->serverOutputBuffer = ''; + } + + private function getServerInput(): InputStream + { + if ($this->serverInput === null) { + throw new RuntimeException('TypeScript translation extractor input stream has not been started.'); + } + + return $this->serverInput; + } + + private function getServerProcess(): Process + { + if ($this->serverProcess === null) { + throw new RuntimeException('TypeScript translation extractor process has not been started.'); + } + + return $this->serverProcess; + } + + /** + * @return array{messages?: mixed, warnings?: mixed, error?: string} + */ + private function readServerLine(): array + { + $process = $this->getServerProcess(); + $deadline = microtime(true) + $this->responseTimeoutSeconds; + + while (!str_contains($this->serverOutputBuffer, "\n")) { + if (!$process->isRunning()) { + $this->discardServerProcess(); + + throw new RuntimeException(sprintf( + 'TypeScript translation extractor process terminated unexpectedly: %s', + trim($process->getErrorOutput()), + )); + } + + if (microtime(true) > $deadline) { + $this->discardServerProcess(); + + throw new RuntimeException( + 'Timed out waiting for the TypeScript translation extractor process to respond.', + ); + } + + $this->serverOutputBuffer .= $process->getIncrementalOutput(); + + if (!str_contains($this->serverOutputBuffer, "\n")) { + usleep(2000); + } + } + + [$line, $rest] = explode("\n", $this->serverOutputBuffer, 2); + $this->serverOutputBuffer = $rest; + + $decoded = json_decode($line, true); + + return is_array($decoded) ? $decoded : ['error' => 'Unable to decode extractor output.']; + } + + /** + * Kills the current server process and discards any buffered output, so that a + * response arriving after a timeout cannot be misread as the answer to a later, + * unrelated request once the process is restarted. + */ + private function discardServerProcess(): void + { + $this->serverInput?->close(); + $this->serverProcess?->stop(3); + + $this->serverProcess = null; + $this->serverInput = null; + $this->serverOutputBuffer = ''; + } +} diff --git a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs new file mode 100644 index 0000000000..fec85fa8c9 --- /dev/null +++ b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import readline from 'node:readline'; +import { parse } from '@typescript-eslint/typescript-estree'; + +const isTranslatorCall = (node) => { + if (!node || node.type !== 'CallExpression') { + return false; + } + + const callee = node.callee; + + return callee?.type === 'MemberExpression' + && callee.object?.type === 'Identifier' + && callee.object.name === 'Translator' + && callee.property?.type === 'Identifier' + && ['trans', 'transChoice'].includes(callee.property.name); +}; + +const getStringLiteralValue = (node) => { + if (!node) { + return null; + } + + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + + return null; +}; + +const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error)); + +const formatWarning = (argumentName, node, filePath) => { + const line = node?.loc?.start?.line ?? 0; + const column = node?.loc?.start?.column ?? 0; + + return `Could not extract ${argumentName}, expected string literal but got ${node?.type ?? 'nothing'} (in ${filePath} on line ${line} column ${column}).`; +}; + +const extractFromFile = (filePath) => { + const source = fs.readFileSync(filePath, 'utf8'); + const ast = parse(source, { + comment: true, + jsx: true, + loc: true, + range: true, + filePath, + }); + + const comments = ast.comments ?? []; + const messages = []; + const warnings = []; + + const findClosestLeadingComment = (node) => { + if (!node?.range) { + return null; + } + + const [nodeStart] = node.range; + let candidate = null; + + for (const comment of comments) { + if (!comment.range || comment.range[1] > nodeStart) { + continue; + } + + const textBetween = source.slice(comment.range[1], nodeStart); + if (!/^\s*$/.test(textBetween)) { + continue; + } + + if (candidate === null || comment.range[1] > candidate.range[1]) { + candidate = comment; + } + } + + return candidate; + }; + + const extractDesc = (node) => { + const comment = findClosestLeadingComment(node); + + if (!comment?.value) { + return null; + } + + const match = comment.value.match(/@Desc\((['"])((?:\\[\s\S]|(?!\1)[\s\S])*)\1\)/); + + return match ? match[2].replace(/\\(['"\\])/g, '$1') : null; + }; + + const visit = (node) => { + if (!node || typeof node !== 'object') { + return; + } + + if (isTranslatorCall(node)) { + const methodName = node.callee.property.name; + const domainArgIndex = methodName === 'trans' ? 2 : 3; + const idNode = node.arguments[0]; + const id = getStringLiteralValue(idNode); + + if (id === null) { + warnings.push(formatWarning('id', idNode, filePath)); + } else { + const domainNode = node.arguments[domainArgIndex]; + const domain = domainNode ? getStringLiteralValue(domainNode) : null; + + if (domainNode && domain === null) { + warnings.push(formatWarning('domain', domainNode, filePath)); + } + + messages.push({ + id, + domain, + desc: extractDesc(idNode), + }); + } + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + value.forEach(visit); + continue; + } + + if (value && typeof value === 'object') { + visit(value); + } + } + }; + + visit(ast); + + return { messages, warnings }; +}; + +const [, , mode] = process.argv; + +if (mode === '--check-runtime') { + process.stdout.write('ok'); + process.exit(0); +} + +if (mode === '--serve') { + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + + rl.on('line', (filePath) => { + if (!filePath) { + process.stdout.write(`${JSON.stringify({ error: 'Empty file path received.' })}\n`); + return; + } + + try { + process.stdout.write(`${JSON.stringify(extractFromFile(filePath))}\n`); + } catch (error) { + process.stdout.write(`${JSON.stringify({ error: getErrorMessage(error) })}\n`); + } + }); + + process.exitCode = 0; +} else { + const filePath = mode; + + if (!filePath) { + process.stderr.write('Missing file path argument.\n'); + process.exit(1); + } + + try { + process.stdout.write(JSON.stringify(extractFromFile(filePath))); + } catch (error) { + process.stderr.write(`${getErrorMessage(error)}\n`); + process.exit(1); + } +} diff --git a/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php b/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php new file mode 100644 index 0000000000..dea6402692 --- /dev/null +++ b/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php @@ -0,0 +1,283 @@ + [], 'warnings' => []]) . "\n"; + } + } + PHP; + + public function testVisitFileAddsMessagesReturnedByExtractorScript(): void + { + [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath] = $this->createFixture(<<<'JSON' + [ + 'messages' => [ + [ + 'id' => 'fixture.translation', + 'domain' => 'ibexa_fixture', + 'desc' => 'Fixture translation', + ], + ], + 'warnings' => [], + ] + JSON); + + $visitor = new TypeScriptFileVisitor( + parserScriptPath: $scriptPath, + nodeBinary: PHP_BINARY, + ); + $catalogue = new MessageCatalogue(); + + try { + $visitor->visitFile(new SplFileInfo($filePath), $catalogue); + + self::assertSame('Fixture translation', $catalogue->get('fixture.translation', 'ibexa_fixture')->getDesc()); + } finally { + $this->cleanupFixture($temporaryDirectory, $filePath, $scriptPath, $startsFilePath); + } + } + + public function testVisitFileReusesPersistentProcessAcrossMultipleFiles(): void + { + [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath] = $this->createFixture(<<<'JSON' + [ + 'messages' => [ + [ + 'id' => 'fixture.translation', + 'domain' => 'ibexa_fixture', + 'desc' => null, + ], + ], + 'warnings' => [], + ] + JSON); + + $visitor = new TypeScriptFileVisitor( + parserScriptPath: $scriptPath, + nodeBinary: PHP_BINARY, + ); + $catalogue = new MessageCatalogue(); + + try { + $visitor->visitFile(new SplFileInfo($filePath), $catalogue); + $visitor->visitFile(new SplFileInfo($filePath), $catalogue); + $visitor->visitFile(new SplFileInfo($filePath), $catalogue); + + self::assertSame('x', file_get_contents($startsFilePath)); + } finally { + $this->cleanupFixture($temporaryDirectory, $filePath, $scriptPath, $startsFilePath); + } + } + + public function testVisitFileLogsWarningsReturnedByExtractorScript(): void + { + [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath] = $this->createFixture(<<<'JSON' + [ + 'messages' => [], + 'warnings' => [ + 'Could not extract domain, expected string literal but got Identifier (in fixture.ts on line 1 column 1).', + ], + ] + JSON); + + $visitor = new TypeScriptFileVisitor( + parserScriptPath: $scriptPath, + nodeBinary: PHP_BINARY, + ); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once()) + ->method('error') + ->with(self::stringContains('Could not extract domain')); + $visitor->setLogger($logger); + + try { + $visitor->visitFile(new SplFileInfo($filePath), new MessageCatalogue()); + } finally { + $this->cleanupFixture($temporaryDirectory, $filePath, $scriptPath, $startsFilePath); + } + } + + public function testVisitFileFailsLoudlyWhenRuntimeIsUnavailable(): void + { + $temporaryDirectory = sys_get_temp_dir() . '/ibexa-ts-visitor-' . bin2hex(random_bytes(4)); + mkdir($temporaryDirectory, 0777, true); + + $filePath = $temporaryDirectory . '/fixture.ts'; + file_put_contents($filePath, 'export const fixture = true;'); + + $scriptPath = $temporaryDirectory . '/fixture-parser.php'; + file_put_contents($scriptPath, <<<'PHP' + expectException(RuntimeException::class); + $this->expectExceptionMessage('TypeScript translation extractor runtime is not available.'); + + try { + $visitor->visitFile(new SplFileInfo($filePath), new MessageCatalogue()); + } finally { + unlink($scriptPath); + unlink($filePath); + rmdir($temporaryDirectory); + } + } + + public function testVisitFileRestartsServerProcessAfterUnexpectedTermination(): void + { + [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath] = $this->createRestartingFixture(); + + $visitor = new TypeScriptFileVisitor( + parserScriptPath: $scriptPath, + nodeBinary: PHP_BINARY, + ); + + $firstException = null; + try { + $visitor->visitFile(new SplFileInfo($filePath), new MessageCatalogue()); + } catch (RuntimeException $exception) { + $firstException = $exception; + } finally { + self::assertSame('x', file_get_contents($startsFilePath)); + } + + self::assertNotNull($firstException); + self::assertStringContainsString('terminated unexpectedly', $firstException->getMessage()); + + try { + $visitor->visitFile(new SplFileInfo($filePath), new MessageCatalogue()); + + self::assertSame('xx', file_get_contents($startsFilePath)); + } finally { + $this->cleanupFixture($temporaryDirectory, $filePath, $scriptPath, $startsFilePath); + } + } + + /** + * @return array{0: string, 1: string, 2: string, 3: string} + */ + private function createFixture(string $responsePhpArrayLiteral): array + { + $temporaryDirectory = $this->createTemporaryDirectory(); + + $filePath = $temporaryDirectory . '/fixture.ts'; + file_put_contents($filePath, 'export const fixture = true;'); + + $startsFilePath = $temporaryDirectory . '/starts.txt'; + file_put_contents($startsFilePath, ''); + + $scriptPath = $temporaryDirectory . '/fixture-parser.php'; + file_put_contents($scriptPath, sprintf( + self::SERVER_FIXTURE_TEMPLATE, + $startsFilePath, + $responsePhpArrayLiteral, + )); + + return [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath]; + } + + /** + * @return array{0: string, 1: string, 2: string, 3: string} + */ + private function createRestartingFixture(): array + { + $temporaryDirectory = $this->createTemporaryDirectory(); + + $filePath = $temporaryDirectory . '/fixture.ts'; + file_put_contents($filePath, 'export const fixture = true;'); + + $startsFilePath = $temporaryDirectory . '/starts.txt'; + file_put_contents($startsFilePath, ''); + + $scriptPath = $temporaryDirectory . '/fixture-parser.php'; + file_put_contents($scriptPath, sprintf( + self::RESTARTING_SERVER_FIXTURE_TEMPLATE, + $startsFilePath, + $startsFilePath, + )); + + return [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath]; + } + + private function createTemporaryDirectory(): string + { + $temporaryDirectory = sys_get_temp_dir() . '/ibexa-ts-visitor-' . bin2hex(random_bytes(4)); + + if (!mkdir($temporaryDirectory, 0777, true) && !is_dir($temporaryDirectory)) { + self::fail(sprintf('Unable to create temporary directory: %s', $temporaryDirectory)); + } + + return $temporaryDirectory; + } + + private function cleanupFixture( + string $temporaryDirectory, + string $filePath, + string $scriptPath, + string $startsFilePath, + ): void { + unlink($scriptPath); + unlink($filePath); + unlink($startsFilePath); + rmdir($temporaryDirectory); + } +} diff --git a/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php b/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php new file mode 100644 index 0000000000..b526442587 --- /dev/null +++ b/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php @@ -0,0 +1,235 @@ + $expectedMessages + * @param array $expectedWarningSubstrings + */ + public function testExtractsExpectedMessages( + string $extension, + string $source, + array $expectedMessages, + array $expectedWarningSubstrings = [], + ): void { + $file = tempnam(sys_get_temp_dir(), 'ts_extractor_'); + rename($file, $file .= '.' . $extension); + file_put_contents($file, $source); + + $process = new Process(['node', $this->getScriptPath(), $file]); + $process->run(); + + self::assertTrue($process->isSuccessful(), $process->getErrorOutput()); + + $decoded = json_decode($process->getOutput(), true); + + self::assertSame($expectedMessages, $decoded['messages']); + self::assertCount($expectedWarningSubstrings === [] ? 0 : count($expectedWarningSubstrings), $decoded['warnings']); + + foreach ($expectedWarningSubstrings as $index => $substring) { + self::assertStringContainsString($substring, $decoded['warnings'][$index]); + } + + unlink($file); + } + + /** + * @return iterable, + * 3?: array + * }> + */ + public static function provideFixtures(): iterable + { + yield 'trans with domain and desc' => [ + 'ts', + <<<'TS' + Translator.trans( + /* @Desc("Save button label") */ 'admin_ui.save', + {}, + 'ibexa_admin_ui' + ); + TS, + [['id' => 'admin_ui.save', 'domain' => 'ibexa_admin_ui', 'desc' => 'Save button label']], + ]; + + yield 'transChoice with domain at index 3' => [ + 'ts', + "Translator.transChoice('admin_ui.items', count, {}, 'ibexa_admin_ui');", + [['id' => 'admin_ui.items', 'domain' => 'ibexa_admin_ui', 'desc' => null]], + ]; + + yield 'trans without domain falls back to null' => [ + 'ts', + "Translator.trans('admin_ui.no_domain');", + [['id' => 'admin_ui.no_domain', 'domain' => null, 'desc' => null]], + ]; + + yield 'unrelated call is ignored' => [ + 'ts', + "SomeOtherObject.trans('not.extracted');", + [], + ]; + + yield 'call inside JSX in a tsx file' => [ + 'tsx', + <<<'TSX' + const Label = () => ( + {Translator.trans('admin_ui.jsx_label', {}, 'ibexa_admin_ui')} + ); + TSX, + [['id' => 'admin_ui.jsx_label', 'domain' => 'ibexa_admin_ui', 'desc' => null]], + ]; + + yield 'typed function with generics does not break parsing' => [ + 'tsx', + <<<'TSX' + interface Props { + count: number; + } + + const format = (value: T): string => String(value); + + export const Component = ({ count }: Props): string => { + return Translator.trans('admin_ui.typed', { count }, 'ibexa_admin_ui'); + }; + TSX, + [['id' => 'admin_ui.typed', 'domain' => 'ibexa_admin_ui', 'desc' => null]], + ]; + + yield 'multiple calls in one file' => [ + 'ts', + <<<'TS' + Translator.trans('admin_ui.first', {}, 'ibexa_admin_ui'); + Translator.trans('admin_ui.second', {}, 'ibexa_admin_ui'); + TS, + [ + ['id' => 'admin_ui.first', 'domain' => 'ibexa_admin_ui', 'desc' => null], + ['id' => 'admin_ui.second', 'domain' => 'ibexa_admin_ui', 'desc' => null], + ], + ]; + + yield 'desc is ignored when comment does not directly precede the id literal' => [ + 'ts', + <<<'TS' + /* @Desc("Unrelated") */ + const unrelated = true; + + Translator.trans('admin_ui.no_desc', {}, 'ibexa_admin_ui'); + TS, + [['id' => 'admin_ui.no_desc', 'domain' => 'ibexa_admin_ui', 'desc' => null]], + ]; + + yield 'desc with escaped double quote inside double-quoted Desc' => [ + 'ts', + <<<'TS' + Translator.trans( + /* @Desc("He said \"hi\" to everyone") */ 'admin_ui.escaped_double', + {}, + 'ibexa_admin_ui' + ); + TS, + [['id' => 'admin_ui.escaped_double', 'domain' => 'ibexa_admin_ui', 'desc' => 'He said "hi" to everyone']], + ]; + + yield 'desc with escaped single quote inside single-quoted Desc' => [ + 'ts', + <<<'TS' + Translator.trans( + /* @Desc('It\'s here') */ 'admin_ui.escaped_single', + {}, + 'ibexa_admin_ui' + ); + TS, + [['id' => 'admin_ui.escaped_single', 'domain' => 'ibexa_admin_ui', 'desc' => "It's here"]], + ]; + + yield 'non-literal id is skipped and warns' => [ + 'ts', + <<<'TS' + const key = 'admin_ui.dynamic'; + Translator.trans(key, {}, 'ibexa_admin_ui'); + TS, + [], + ['Could not extract id, expected string literal but got Identifier'], + ]; + + yield 'non-literal domain still yields the message but warns' => [ + 'ts', + <<<'TS' + const domain = 'ibexa_admin_ui'; + Translator.trans('admin_ui.dynamic_domain', {}, domain); + TS, + [['id' => 'admin_ui.dynamic_domain', 'domain' => null, 'desc' => null]], + ['Could not extract domain, expected string literal but got Identifier'], + ]; + + yield 'missing arguments entirely still warns about id' => [ + 'ts', + 'Translator.trans();', + [], + ['Could not extract id, expected string literal but got nothing'], + ]; + } + + public function testServeModeProcessesMultipleFilesSequentiallyAndSurvivesPerFileErrors(): void + { + $goodFile = tempnam(sys_get_temp_dir(), 'ts_extractor_') . '.ts'; + file_put_contents($goodFile, "Translator.trans('admin_ui.good', {}, 'ibexa_admin_ui');"); + + $badFile = tempnam(sys_get_temp_dir(), 'ts_extractor_') . '.ts'; + file_put_contents($badFile, 'const x: = ;'); + + $process = new Process(['node', $this->getScriptPath(), '--serve']); + $process->setInput($goodFile . "\n" . $badFile . "\n" . $goodFile . "\n"); + $process->run(); + + self::assertTrue($process->isSuccessful(), $process->getErrorOutput()); + + $lines = array_values(array_filter(explode("\n", $process->getOutput()))); + self::assertCount(3, $lines); + + $expectedMessage = [['id' => 'admin_ui.good', 'domain' => 'ibexa_admin_ui', 'desc' => null]]; + + self::assertSame($expectedMessage, json_decode($lines[0], true)['messages']); + self::assertArrayHasKey('error', json_decode($lines[1], true)); + self::assertSame($expectedMessage, json_decode($lines[2], true)['messages']); + + unlink($goodFile); + unlink($badFile); + } + + public function testExitsWithNonZeroStatusOnSyntaxError(): void + { + $file = tempnam(sys_get_temp_dir(), 'ts_extractor_') . '.ts'; + file_put_contents($file, 'const x: = ;'); + + $process = new Process(['node', $this->getScriptPath(), $file]); + $process->run(); + + self::assertFalse($process->isSuccessful()); + self::assertNotSame('', trim($process->getErrorOutput())); + + unlink($file); + } + + private function getScriptPath(): string + { + return dirname(__DIR__, 4) . '/src/lib/Translation/Extractor/typescript_translation_extractor.mjs'; + } +} From 8a5efdafba3e79af9f2506ce3909c6ea1f49d73e Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 16 Jul 2026 10:15:47 +0200 Subject: [PATCH 2/6] IBX-12091: Improved TypeScript translation extractor structure --- .../config/services/translation.yaml | 2 + .../Extractor/TypeScriptExtractorClient.php | 176 +++++++++++++ .../Extractor/TypeScriptFileVisitor.php | 233 ++++-------------- .../typescript_translation_extractor.mjs | 166 +++++++------ .../Extractor/TypeScriptFileVisitorTest.php | 34 ++- 5 files changed, 336 insertions(+), 275 deletions(-) create mode 100644 src/lib/Translation/Extractor/TypeScriptExtractorClient.php diff --git a/src/bundle/Resources/config/services/translation.yaml b/src/bundle/Resources/config/services/translation.yaml index 2a9ea58fc9..d538875d66 100644 --- a/src/bundle/Resources/config/services/translation.yaml +++ b/src/bundle/Resources/config/services/translation.yaml @@ -12,6 +12,8 @@ services: tags: - { name: jms_translation.file_visitor } + Ibexa\AdminUi\Translation\Extractor\TypeScriptExtractorClient: ~ + Ibexa\AdminUi\Translation\Extractor\SortingTranslationExtractor: tags: - { name: jms_translation.extractor, alias: ez_location_sorting } diff --git a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php new file mode 100644 index 0000000000..67d435b34f --- /dev/null +++ b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php @@ -0,0 +1,176 @@ +parserScriptPath = $parserScriptPath ?? __DIR__ . '/typescript_translation_extractor.mjs'; + } + + public function __destruct() + { + $this->discardServerProcess(); + } + + /** + * @return array{messages?: mixed, warnings?: mixed, error?: string} + */ + public function extract(string $filePath): array + { + $this->assertRuntimeIsReady(); + $this->ensureServerStarted(); + + $this->getServerInput()->write($filePath . "\n"); + + return $this->readServerLine(); + } + + private function assertRuntimeIsReady(): void + { + if ($this->runtimeReady === true) { + return; + } + + if (!is_file($this->parserScriptPath)) { + throw new RuntimeException(sprintf( + 'TypeScript translation extractor script not found: %s.', + $this->parserScriptPath, + )); + } + + $process = new Process([ + $this->nodeBinary, + $this->parserScriptPath, + '--check-runtime', + ]); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf( + 'TypeScript translation extractor runtime is not available. Please ensure `%s` is installed and `@typescript-eslint/typescript-estree` is available for the admin-ui package. %s', + $this->nodeBinary, + trim($process->getErrorOutput()), + )); + } + + $this->runtimeReady = true; + } + + private function ensureServerStarted(): void + { + if ($this->serverProcess !== null && $this->serverProcess->isRunning()) { + return; + } + + $input = new InputStream(); + + $process = new Process([ + $this->nodeBinary, + $this->parserScriptPath, + '--serve', + ]); + $process->setInput($input); + $process->setTimeout(null); + $process->start(); + + $this->serverInput = $input; + $this->serverProcess = $process; + $this->serverOutputBuffer = ''; + } + + private function getServerInput(): InputStream + { + if ($this->serverInput === null) { + throw new RuntimeException('TypeScript translation extractor input stream has not been started.'); + } + + return $this->serverInput; + } + + private function getServerProcess(): Process + { + if ($this->serverProcess === null) { + throw new RuntimeException('TypeScript translation extractor process has not been started.'); + } + + return $this->serverProcess; + } + + /** + * @return array{messages?: mixed, warnings?: mixed, error?: string} + */ + private function readServerLine(): array + { + $process = $this->getServerProcess(); + $deadline = microtime(true) + $this->responseTimeoutSeconds; + + while (!str_contains($this->serverOutputBuffer, "\n")) { + if (!$process->isRunning()) { + $this->discardServerProcess(); + + throw new RuntimeException(sprintf( + 'TypeScript translation extractor process terminated unexpectedly: %s', + trim($process->getErrorOutput()), + )); + } + + if (microtime(true) > $deadline) { + $this->discardServerProcess(); + + throw new RuntimeException( + 'Timed out waiting for the TypeScript translation extractor process to respond.', + ); + } + + $this->serverOutputBuffer .= $process->getIncrementalOutput(); + + if (!str_contains($this->serverOutputBuffer, "\n")) { + usleep(2000); + } + } + + [$line, $rest] = explode("\n", $this->serverOutputBuffer, 2); + $this->serverOutputBuffer = $rest; + + $decoded = json_decode($line, true); + + return is_array($decoded) ? $decoded : ['error' => 'Unable to decode extractor output.']; + } + + private function discardServerProcess(): void + { + $this->serverInput?->close(); + $this->serverProcess?->stop(3); + + $this->serverProcess = null; + $this->serverInput = null; + $this->serverOutputBuffer = ''; + } +} diff --git a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php index 4d8a814427..82cb4405af 100644 --- a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php +++ b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php @@ -15,41 +15,18 @@ use JMS\TranslationBundle\Translation\Extractor\FileVisitorInterface; use Psr\Log\LoggerAwareTrait; use Psr\Log\NullLogger; -use RuntimeException; use SplFileInfo; -use Symfony\Component\Process\InputStream; -use Symfony\Component\Process\Process; use Twig\Node\Node as TwigNode; final class TypeScriptFileVisitor implements FileVisitorInterface, LoggerAwareInterface { use LoggerAwareTrait; - private const DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0; - - private string $parserScriptPath; - - private ?bool $runtimeReady = null; - - private ?Process $serverProcess = null; - - private ?InputStream $serverInput = null; - - private string $serverOutputBuffer = ''; - public function __construct( + private readonly TypeScriptExtractorClient $extractorClient, private readonly string $defaultDomain = 'messages', - ?string $parserScriptPath = null, - private readonly string $nodeBinary = 'node', - private readonly float $responseTimeoutSeconds = self::DEFAULT_RESPONSE_TIMEOUT_SECONDS, ) { $this->logger = new NullLogger(); - $this->parserScriptPath = $parserScriptPath ?? __DIR__ . '/typescript_translation_extractor.mjs'; - } - - public function __destruct() - { - $this->discardServerProcess(); } public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void @@ -58,9 +35,27 @@ public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void return; } - $this->assertRuntimeIsReady(); + $messages = $this->extractMessages($file); + + if ($messages === null) { + return; + } + + foreach ($messages as $messageData) { + if (!is_array($messageData) || !isset($messageData['id']) || !is_string($messageData['id'])) { + continue; + } - $decoded = $this->requestExtraction((string) $file->getRealPath()); + $catalogue->add($this->createMessage($file, $messageData)); + } + } + + /** + * @return array|null + */ + private function extractMessages(SplFileInfo $file): ?array + { + $decoded = $this->extractorClient->extract((string) $file->getRealPath()); if (isset($decoded['error'])) { $this->logger?->error(sprintf( @@ -69,7 +64,7 @@ public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void $decoded['error'], )); - return; + return null; } if (!isset($decoded['messages']) || !is_array($decoded['messages'])) { @@ -78,190 +73,66 @@ public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void $file->getRealPath(), )); - return; - } - - foreach ($decoded['warnings'] ?? [] as $warning) { - if (is_string($warning)) { - $this->logger?->error($warning); - } + return null; } - foreach ($decoded['messages'] as $messageData) { - if (!is_array($messageData) || !isset($messageData['id']) || !is_string($messageData['id'])) { - continue; - } - - $message = new Message( - $messageData['id'], - isset($messageData['domain']) && is_string($messageData['domain']) ? $messageData['domain'] : $this->defaultDomain, - ); + $this->logWarnings($decoded['warnings'] ?? []); - if (isset($messageData['desc']) && is_string($messageData['desc'])) { - $message->setDesc($messageData['desc']); - } - - $message->addSource(new FileSource((string) $file)); - $catalogue->add($message); - } + return $decoded['messages']; } /** - * @param array $ast + * @param mixed $warnings */ - public function visitPhpFile(SplFileInfo $file, MessageCatalogue $catalogue, array $ast): void - { - } - - public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast): void - { - } - - private function supports(SplFileInfo $file): bool + private function logWarnings(mixed $warnings): void { - $path = $file->getRealPath(); - - return ($path !== false) - && (str_ends_with($path, '.ts') || str_ends_with($path, '.tsx')) - && !str_ends_with($path, '.d.ts'); - } - - private function assertRuntimeIsReady(): void - { - if ($this->runtimeReady === true) { + if (!is_array($warnings)) { return; } - if (!is_file($this->parserScriptPath)) { - throw new RuntimeException(sprintf( - 'TypeScript translation extractor script not found: %s.', - $this->parserScriptPath, - )); - } - - $process = new Process([ - $this->nodeBinary, - $this->parserScriptPath, - '--check-runtime', - ]); - $process->run(); - - if (!$process->isSuccessful()) { - throw new RuntimeException(sprintf( - 'TypeScript translation extractor runtime is not available. Please ensure `%s` is installed and `@typescript-eslint/typescript-estree` is available for the admin-ui package. %s', - $this->nodeBinary, - trim($process->getErrorOutput()), - )); + foreach ($warnings as $warning) { + if (is_string($warning)) { + $this->logger?->error($warning); + } } - - $this->runtimeReady = true; } /** - * @return array{messages?: mixed, warnings?: mixed, error?: string} + * @param array{id: string, domain?: mixed, desc?: mixed} $messageData */ - private function requestExtraction(string $filePath): array + private function createMessage(SplFileInfo $file, array $messageData): Message { - $this->ensureServerStarted(); - - $this->getServerInput()->write($filePath . "\n"); - - return $this->readServerLine(); - } + $message = new Message( + $messageData['id'], + isset($messageData['domain']) && is_string($messageData['domain']) ? $messageData['domain'] : $this->defaultDomain, + ); - private function ensureServerStarted(): void - { - if ($this->serverProcess !== null && $this->serverProcess->isRunning()) { - return; + if (isset($messageData['desc']) && is_string($messageData['desc'])) { + $message->setDesc($messageData['desc']); } - $input = new InputStream(); - - $process = new Process([ - $this->nodeBinary, - $this->parserScriptPath, - '--serve', - ]); - $process->setInput($input); - $process->setTimeout(null); - $process->start(); + $message->addSource(new FileSource((string) $file)); - $this->serverInput = $input; - $this->serverProcess = $process; - $this->serverOutputBuffer = ''; - } - - private function getServerInput(): InputStream - { - if ($this->serverInput === null) { - throw new RuntimeException('TypeScript translation extractor input stream has not been started.'); - } - - return $this->serverInput; - } - - private function getServerProcess(): Process - { - if ($this->serverProcess === null) { - throw new RuntimeException('TypeScript translation extractor process has not been started.'); - } - - return $this->serverProcess; + return $message; } /** - * @return array{messages?: mixed, warnings?: mixed, error?: string} + * @param array $ast */ - private function readServerLine(): array + public function visitPhpFile(SplFileInfo $file, MessageCatalogue $catalogue, array $ast): void { - $process = $this->getServerProcess(); - $deadline = microtime(true) + $this->responseTimeoutSeconds; - - while (!str_contains($this->serverOutputBuffer, "\n")) { - if (!$process->isRunning()) { - $this->discardServerProcess(); - - throw new RuntimeException(sprintf( - 'TypeScript translation extractor process terminated unexpectedly: %s', - trim($process->getErrorOutput()), - )); - } - - if (microtime(true) > $deadline) { - $this->discardServerProcess(); - - throw new RuntimeException( - 'Timed out waiting for the TypeScript translation extractor process to respond.', - ); - } - - $this->serverOutputBuffer .= $process->getIncrementalOutput(); - - if (!str_contains($this->serverOutputBuffer, "\n")) { - usleep(2000); - } - } - - [$line, $rest] = explode("\n", $this->serverOutputBuffer, 2); - $this->serverOutputBuffer = $rest; - - $decoded = json_decode($line, true); + } - return is_array($decoded) ? $decoded : ['error' => 'Unable to decode extractor output.']; + public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast): void + { } - /** - * Kills the current server process and discards any buffered output, so that a - * response arriving after a timeout cannot be misread as the answer to a later, - * unrelated request once the process is restarted. - */ - private function discardServerProcess(): void + private function supports(SplFileInfo $file): bool { - $this->serverInput?->close(); - $this->serverProcess?->stop(3); + $path = $file->getRealPath(); - $this->serverProcess = null; - $this->serverInput = null; - $this->serverOutputBuffer = ''; + return ($path !== false) + && (str_ends_with($path, '.ts') || str_ends_with($path, '.tsx')) + && !str_ends_with($path, '.d.ts'); } } diff --git a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs index fec85fa8c9..9354fdafaa 100644 --- a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs +++ b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs @@ -30,6 +30,11 @@ const getStringLiteralValue = (node) => { const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error)); +const TRANSLATOR_ARGUMENTS = { + trans: { id: 0, domain: 2 }, + transChoice: { id: 0, domain: 3 }, +}; + const formatWarning = (argumentName, node, filePath) => { const line = node?.loc?.start?.line ?? 0; const column = node?.loc?.start?.column ?? 0; @@ -37,102 +42,115 @@ const formatWarning = (argumentName, node, filePath) => { return `Could not extract ${argumentName}, expected string literal but got ${node?.type ?? 'nothing'} (in ${filePath} on line ${line} column ${column}).`; }; -const extractFromFile = (filePath) => { - const source = fs.readFileSync(filePath, 'utf8'); - const ast = parse(source, { - comment: true, - jsx: true, - loc: true, - range: true, - filePath, - }); +const findClosestLeadingComment = (source, comments, node) => { + if (!node?.range) { + return null; + } - const comments = ast.comments ?? []; - const messages = []; - const warnings = []; + const [nodeStart] = node.range; + let candidate = null; - const findClosestLeadingComment = (node) => { - if (!node?.range) { - return null; + for (const comment of comments) { + if (!comment.range || comment.range[1] > nodeStart) { + continue; } - const [nodeStart] = node.range; - let candidate = null; + const textBetween = source.slice(comment.range[1], nodeStart); + if (!/^\s*$/.test(textBetween)) { + continue; + } - for (const comment of comments) { - if (!comment.range || comment.range[1] > nodeStart) { - continue; - } + if (candidate === null || comment.range[1] > candidate.range[1]) { + candidate = comment; + } + } - const textBetween = source.slice(comment.range[1], nodeStart); - if (!/^\s*$/.test(textBetween)) { - continue; - } + return candidate; +}; - if (candidate === null || comment.range[1] > candidate.range[1]) { - candidate = comment; - } - } +const extractDescription = (source, comments, node) => { + const comment = findClosestLeadingComment(source, comments, node); - return candidate; - }; + if (!comment?.value) { + return null; + } - const extractDesc = (node) => { - const comment = findClosestLeadingComment(node); + const match = comment.value.match(/@Desc\((['"])((?:\\[\s\S]|(?!\1)[\s\S])*)\1\)/); - if (!comment?.value) { - return null; - } + return match ? match[2].replace(/\\(['"\\])/g, '$1') : null; +}; + +const extractMessage = (node, filePath, source, comments, warnings) => { + const methodName = node.callee.property.name; + const { id: idArgIndex, domain: domainArgIndex } = TRANSLATOR_ARGUMENTS[methodName]; + const idNode = node.arguments[idArgIndex]; + const id = getStringLiteralValue(idNode); + + if (id === null) { + warnings.push(formatWarning('id', idNode, filePath)); + return null; + } - const match = comment.value.match(/@Desc\((['"])((?:\\[\s\S]|(?!\1)[\s\S])*)\1\)/); + const domainNode = node.arguments[domainArgIndex]; + const domain = domainNode ? getStringLiteralValue(domainNode) : null; + + if (domainNode && domain === null) { + warnings.push(formatWarning('domain', domainNode, filePath)); + } - return match ? match[2].replace(/\\(['"\\])/g, '$1') : null; + return { + id, + domain, + desc: extractDescription(source, comments, idNode), }; +}; - const visit = (node) => { - if (!node || typeof node !== 'object') { - return; - } +const visitNode = (node, context) => { + if (!node || typeof node !== 'object') { + return; + } + + if (isTranslatorCall(node)) { + const message = extractMessage(node, context.filePath, context.source, context.comments, context.warnings); - if (isTranslatorCall(node)) { - const methodName = node.callee.property.name; - const domainArgIndex = methodName === 'trans' ? 2 : 3; - const idNode = node.arguments[0]; - const id = getStringLiteralValue(idNode); - - if (id === null) { - warnings.push(formatWarning('id', idNode, filePath)); - } else { - const domainNode = node.arguments[domainArgIndex]; - const domain = domainNode ? getStringLiteralValue(domainNode) : null; - - if (domainNode && domain === null) { - warnings.push(formatWarning('domain', domainNode, filePath)); - } - - messages.push({ - id, - domain, - desc: extractDesc(idNode), - }); - } + if (message !== null) { + context.messages.push(message); } + } - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - value.forEach(visit); - continue; - } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + value.forEach((child) => visitNode(child, context)); + continue; + } - if (value && typeof value === 'object') { - visit(value); - } + if (value && typeof value === 'object') { + visitNode(value, context); } + } +}; + +const extractFromFile = (filePath) => { + const source = fs.readFileSync(filePath, 'utf8'); + const ast = parse(source, { + comment: true, + jsx: true, + loc: true, + range: true, + filePath, + }); + + const context = { + filePath, + source, + comments: ast.comments ?? [], + messages: [], + warnings: [], }; - visit(ast); + visitNode(ast, context); - return { messages, warnings }; + return { messages: context.messages, warnings: context.warnings }; }; const [, , mode] = process.argv; diff --git a/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php b/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php index dea6402692..c239c98148 100644 --- a/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php +++ b/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php @@ -8,6 +8,7 @@ namespace Ibexa\Tests\AdminUi\Translation\Extractor; +use Ibexa\AdminUi\Translation\Extractor\TypeScriptExtractorClient; use Ibexa\AdminUi\Translation\Extractor\TypeScriptFileVisitor; use JMS\TranslationBundle\Model\MessageCatalogue; use PHPUnit\Framework\TestCase; @@ -73,10 +74,7 @@ public function testVisitFileAddsMessagesReturnedByExtractorScript(): void ] JSON); - $visitor = new TypeScriptFileVisitor( - parserScriptPath: $scriptPath, - nodeBinary: PHP_BINARY, - ); + $visitor = $this->createVisitor($scriptPath); $catalogue = new MessageCatalogue(); try { @@ -103,10 +101,7 @@ public function testVisitFileReusesPersistentProcessAcrossMultipleFiles(): void ] JSON); - $visitor = new TypeScriptFileVisitor( - parserScriptPath: $scriptPath, - nodeBinary: PHP_BINARY, - ); + $visitor = $this->createVisitor($scriptPath); $catalogue = new MessageCatalogue(); try { @@ -131,10 +126,7 @@ public function testVisitFileLogsWarningsReturnedByExtractorScript(): void ] JSON); - $visitor = new TypeScriptFileVisitor( - parserScriptPath: $scriptPath, - nodeBinary: PHP_BINARY, - ); + $visitor = $this->createVisitor($scriptPath); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once()) @@ -165,10 +157,7 @@ public function testVisitFileFailsLoudlyWhenRuntimeIsUnavailable(): void exit(1); PHP); - $visitor = new TypeScriptFileVisitor( - parserScriptPath: $scriptPath, - nodeBinary: PHP_BINARY, - ); + $visitor = $this->createVisitor($scriptPath); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('TypeScript translation extractor runtime is not available.'); @@ -186,10 +175,7 @@ public function testVisitFileRestartsServerProcessAfterUnexpectedTermination(): { [$temporaryDirectory, $filePath, $scriptPath, $startsFilePath] = $this->createRestartingFixture(); - $visitor = new TypeScriptFileVisitor( - parserScriptPath: $scriptPath, - nodeBinary: PHP_BINARY, - ); + $visitor = $this->createVisitor($scriptPath); $firstException = null; try { @@ -269,6 +255,14 @@ private function createTemporaryDirectory(): string return $temporaryDirectory; } + private function createVisitor(string $scriptPath): TypeScriptFileVisitor + { + return new TypeScriptFileVisitor(new TypeScriptExtractorClient( + parserScriptPath: $scriptPath, + nodeBinary: PHP_BINARY, + )); + } + private function cleanupFixture( string $temporaryDirectory, string $filePath, From f1532dc5004c4f70cc349b0617ec09f6ca6e0b0a Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 16 Jul 2026 10:21:06 +0200 Subject: [PATCH 3/6] IBX-12091: Simplified runtime readiness state --- src/lib/Translation/Extractor/TypeScriptExtractorClient.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php index 67d435b34f..82322ee2a4 100644 --- a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php +++ b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php @@ -18,7 +18,7 @@ final class TypeScriptExtractorClient private string $parserScriptPath; - private ?bool $runtimeReady = null; + private bool $isRuntimeReady = false; private ?Process $serverProcess = null; @@ -54,7 +54,7 @@ public function extract(string $filePath): array private function assertRuntimeIsReady(): void { - if ($this->runtimeReady === true) { + if ($this->isRuntimeReady) { return; } @@ -80,7 +80,7 @@ private function assertRuntimeIsReady(): void )); } - $this->runtimeReady = true; + $this->isRuntimeReady = true; } private function ensureServerStarted(): void From 2d44f1c523264811dc923956d58b66e41f57463f Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 16 Jul 2026 10:37:03 +0200 Subject: [PATCH 4/6] IBX-12091: Clarified TypeScript extractor naming --- .../Extractor/TypeScriptExtractorClient.php | 67 ++++++++++++------- .../Extractor/TypeScriptFileVisitor.php | 26 +++---- .../typescript_translation_extractor.mjs | 16 ++--- ...peScriptTranslationExtractorScriptTest.php | 8 +-- 4 files changed, 67 insertions(+), 50 deletions(-) diff --git a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php index 82322ee2a4..4b2b5e433b 100644 --- a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php +++ b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php @@ -8,6 +8,7 @@ namespace Ibexa\AdminUi\Translation\Extractor; +use JsonException; use RuntimeException; use Symfony\Component\Process\InputStream; use Symfony\Component\Process\Process; @@ -24,7 +25,7 @@ final class TypeScriptExtractorClient private ?InputStream $serverInput = null; - private string $serverOutputBuffer = ''; + private string $responseBuffer = ''; public function __construct( ?string $parserScriptPath = null, @@ -49,7 +50,7 @@ public function extract(string $filePath): array $this->getServerInput()->write($filePath . "\n"); - return $this->readServerLine(); + return $this->readExtractionResponse(); } private function assertRuntimeIsReady(): void @@ -65,18 +66,18 @@ private function assertRuntimeIsReady(): void )); } - $process = new Process([ + $runtimeCheckProcess = new Process([ $this->nodeBinary, $this->parserScriptPath, '--check-runtime', ]); - $process->run(); + $runtimeCheckProcess->run(); - if (!$process->isSuccessful()) { + if (!$runtimeCheckProcess->isSuccessful()) { throw new RuntimeException(sprintf( 'TypeScript translation extractor runtime is not available. Please ensure `%s` is installed and `@typescript-eslint/typescript-estree` is available for the admin-ui package. %s', $this->nodeBinary, - trim($process->getErrorOutput()), + trim($runtimeCheckProcess->getErrorOutput()), )); } @@ -89,20 +90,20 @@ private function ensureServerStarted(): void return; } - $input = new InputStream(); + $processInput = new InputStream(); - $process = new Process([ + $extractorProcess = new Process([ $this->nodeBinary, $this->parserScriptPath, '--serve', ]); - $process->setInput($input); - $process->setTimeout(null); - $process->start(); + $extractorProcess->setInput($processInput); + $extractorProcess->setTimeout(null); + $extractorProcess->start(); - $this->serverInput = $input; - $this->serverProcess = $process; - $this->serverOutputBuffer = ''; + $this->serverInput = $processInput; + $this->serverProcess = $extractorProcess; + $this->responseBuffer = ''; } private function getServerInput(): InputStream @@ -126,12 +127,12 @@ private function getServerProcess(): Process /** * @return array{messages?: mixed, warnings?: mixed, error?: string} */ - private function readServerLine(): array + private function readExtractionResponse(): array { $process = $this->getServerProcess(); - $deadline = microtime(true) + $this->responseTimeoutSeconds; + $responseDeadline = microtime(true) + $this->responseTimeoutSeconds; - while (!str_contains($this->serverOutputBuffer, "\n")) { + while (!str_contains($this->responseBuffer, "\n")) { if (!$process->isRunning()) { $this->discardServerProcess(); @@ -141,7 +142,7 @@ private function readServerLine(): array )); } - if (microtime(true) > $deadline) { + if (microtime(true) > $responseDeadline) { $this->discardServerProcess(); throw new RuntimeException( @@ -149,19 +150,35 @@ private function readServerLine(): array ); } - $this->serverOutputBuffer .= $process->getIncrementalOutput(); + $this->responseBuffer .= $process->getIncrementalOutput(); - if (!str_contains($this->serverOutputBuffer, "\n")) { + if (!str_contains($this->responseBuffer, "\n")) { usleep(2000); } } - [$line, $rest] = explode("\n", $this->serverOutputBuffer, 2); - $this->serverOutputBuffer = $rest; + [$responseLine, $rest] = explode("\n", $this->responseBuffer, 2); + $this->responseBuffer = $rest; - $decoded = json_decode($line, true); + return $this->decodeResponse($responseLine); + } + + /** + * @return array{messages?: mixed, warnings?: mixed, error?: string} + */ + private function decodeResponse(string $responseLine): array + { + try { + $responseData = json_decode($responseLine, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return ['error' => 'Unable to decode extractor output.']; + } + + if (!is_array($responseData)) { + return ['error' => 'Extractor output must be a JSON object.']; + } - return is_array($decoded) ? $decoded : ['error' => 'Unable to decode extractor output.']; + return $responseData; } private function discardServerProcess(): void @@ -171,6 +188,6 @@ private function discardServerProcess(): void $this->serverProcess = null; $this->serverInput = null; - $this->serverOutputBuffer = ''; + $this->responseBuffer = ''; } } diff --git a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php index 82cb4405af..3bb7c9ee61 100644 --- a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php +++ b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php @@ -35,13 +35,13 @@ public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void return; } - $messages = $this->extractMessages($file); + $extractedMessages = $this->extractMessages($file); - if ($messages === null) { + if ($extractedMessages === null) { return; } - foreach ($messages as $messageData) { + foreach ($extractedMessages as $messageData) { if (!is_array($messageData) || !isset($messageData['id']) || !is_string($messageData['id'])) { continue; } @@ -55,19 +55,19 @@ public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void */ private function extractMessages(SplFileInfo $file): ?array { - $decoded = $this->extractorClient->extract((string) $file->getRealPath()); + $extractionResponse = $this->extractorClient->extract((string) $file->getRealPath()); - if (isset($decoded['error'])) { + if (isset($extractionResponse['error'])) { $this->logger?->error(sprintf( 'Unable to parse TypeScript file %s: %s', $file->getRealPath(), - $decoded['error'], + $extractionResponse['error'], )); return null; } - if (!isset($decoded['messages']) || !is_array($decoded['messages'])) { + if (!isset($extractionResponse['messages']) || !is_array($extractionResponse['messages'])) { $this->logger?->error(sprintf( 'Unable to decode TypeScript extractor output for file %s.', $file->getRealPath(), @@ -76,9 +76,9 @@ private function extractMessages(SplFileInfo $file): ?array return null; } - $this->logWarnings($decoded['warnings'] ?? []); + $this->logWarnings($extractionResponse['warnings'] ?? []); - return $decoded['messages']; + return $extractionResponse['messages']; } /** @@ -129,10 +129,10 @@ public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, Tw private function supports(SplFileInfo $file): bool { - $path = $file->getRealPath(); + $realPath = $file->getRealPath(); - return ($path !== false) - && (str_ends_with($path, '.ts') || str_ends_with($path, '.tsx')) - && !str_ends_with($path, '.d.ts'); + return ($realPath !== false) + && (str_ends_with($realPath, '.ts') || str_ends_with($realPath, '.tsx')) + && !str_ends_with($realPath, '.d.ts'); } } diff --git a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs index 9354fdafaa..7f7160616a 100644 --- a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs +++ b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs @@ -36,10 +36,10 @@ const TRANSLATOR_ARGUMENTS = { }; const formatWarning = (argumentName, node, filePath) => { - const line = node?.loc?.start?.line ?? 0; - const column = node?.loc?.start?.column ?? 0; + const sourceLine = node?.loc?.start?.line ?? 0; + const sourceColumn = node?.loc?.start?.column ?? 0; - return `Could not extract ${argumentName}, expected string literal but got ${node?.type ?? 'nothing'} (in ${filePath} on line ${line} column ${column}).`; + return `Could not extract ${argumentName}, expected string literal but got ${node?.type ?? 'nothing'} (in ${filePath} on line ${sourceLine} column ${sourceColumn}).`; }; const findClosestLeadingComment = (source, comments, node) => { @@ -118,14 +118,14 @@ const visitNode = (node, context) => { } } - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - value.forEach((child) => visitNode(child, context)); + for (const childNode of Object.values(node)) { + if (Array.isArray(childNode)) { + childNode.forEach((nestedNode) => visitNode(nestedNode, context)); continue; } - if (value && typeof value === 'object') { - visitNode(value, context); + if (childNode && typeof childNode === 'object') { + visitNode(childNode, context); } } }; diff --git a/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php b/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php index b526442587..4d7af30f0d 100644 --- a/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php +++ b/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php @@ -34,13 +34,13 @@ public function testExtractsExpectedMessages( self::assertTrue($process->isSuccessful(), $process->getErrorOutput()); - $decoded = json_decode($process->getOutput(), true); + $extractionResponse = json_decode($process->getOutput(), true); - self::assertSame($expectedMessages, $decoded['messages']); - self::assertCount($expectedWarningSubstrings === [] ? 0 : count($expectedWarningSubstrings), $decoded['warnings']); + self::assertSame($expectedMessages, $extractionResponse['messages']); + self::assertCount($expectedWarningSubstrings === [] ? 0 : count($expectedWarningSubstrings), $extractionResponse['warnings']); foreach ($expectedWarningSubstrings as $index => $substring) { - self::assertStringContainsString($substring, $decoded['warnings'][$index]); + self::assertStringContainsString($substring, $extractionResponse['warnings'][$index]); } unlink($file); From 8b44786129e0915258bbc27cb073a989ed63b4db Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 16 Jul 2026 10:45:49 +0200 Subject: [PATCH 5/6] IBX-12091: Installed frontend dependencies for backend tests --- .github/workflows/backend-ci.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/backend-ci.yaml b/.github/workflows/backend-ci.yaml index ee9027660b..476345ffb5 100644 --- a/.github/workflows/backend-ci.yaml +++ b/.github/workflows/backend-ci.yaml @@ -84,6 +84,13 @@ jobs: satis-network-key: ${{ secrets.SATIS_NETWORK_KEY }} satis-network-token: ${{ secrets.SATIS_NETWORK_TOKEN }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install frontend dependencies + run: yarn install + - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" @@ -131,6 +138,13 @@ jobs: satis-network-key: ${{ secrets.SATIS_NETWORK_KEY }} satis-network-token: ${{ secrets.SATIS_NETWORK_TOKEN }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install frontend dependencies + run: yarn install + - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" @@ -182,6 +196,13 @@ jobs: satis-network-key: ${{ secrets.SATIS_NETWORK_KEY }} satis-network-token: ${{ secrets.SATIS_NETWORK_TOKEN }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install frontend dependencies + run: yarn install + - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" From 8b9bbb0939bbd75dd7cca0c4a7ce3ce23a7ff179 Mon Sep 17 00:00:00 2001 From: Aleksandra Bozek Date: Mon, 20 Jul 2026 14:35:32 +0200 Subject: [PATCH 6/6] After cr --- .github/workflows/backend-ci.yaml | 6 +-- .../typescript_translation_extractor.mjs | 40 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.github/workflows/backend-ci.yaml b/.github/workflows/backend-ci.yaml index 476345ffb5..8808eee9b7 100644 --- a/.github/workflows/backend-ci.yaml +++ b/.github/workflows/backend-ci.yaml @@ -86,7 +86,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Install frontend dependencies run: yarn install @@ -140,7 +140,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Install frontend dependencies run: yarn install @@ -198,7 +198,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Install frontend dependencies run: yarn install diff --git a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs index 7f7160616a..c63e49acc2 100644 --- a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs +++ b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs @@ -1,27 +1,26 @@ import fs from 'node:fs'; import readline from 'node:readline'; +import { parseArgs } from 'node:util'; import { parse } from '@typescript-eslint/typescript-estree'; const isTranslatorCall = (node) => { - if (!node || node.type !== 'CallExpression') { + if (node?.type !== 'CallExpression') { return false; } - const callee = node.callee; + const { callee } = node; - return callee?.type === 'MemberExpression' - && callee.object?.type === 'Identifier' - && callee.object.name === 'Translator' - && callee.property?.type === 'Identifier' - && ['trans', 'transChoice'].includes(callee.property.name); + return ( + callee?.type === 'MemberExpression' && + callee.object?.type === 'Identifier' && + callee.object.name === 'Translator' && + callee.property?.type === 'Identifier' && + ['trans', 'transChoice'].includes(callee.property.name) + ); }; const getStringLiteralValue = (node) => { - if (!node) { - return null; - } - - if (node.type === 'Literal' && typeof node.value === 'string') { + if (node?.type === 'Literal' && typeof node?.value === 'string') { return node.value; } @@ -56,6 +55,7 @@ const findClosestLeadingComment = (source, comments, node) => { } const textBetween = source.slice(comment.range[1], nodeStart); + if (!/^\s*$/.test(textBetween)) { continue; } @@ -88,6 +88,7 @@ const extractMessage = (node, filePath, source, comments, warnings) => { if (id === null) { warnings.push(formatWarning('id', idNode, filePath)); + return null; } @@ -139,7 +140,6 @@ const extractFromFile = (filePath) => { range: true, filePath, }); - const context = { filePath, source, @@ -153,14 +153,20 @@ const extractFromFile = (filePath) => { return { messages: context.messages, warnings: context.warnings }; }; -const [, , mode] = process.argv; +const { values, positionals } = parseArgs({ + options: { + 'check-runtime': { type: 'boolean' }, + serve: { type: 'boolean' }, + }, + allowPositionals: true, +}); -if (mode === '--check-runtime') { +if (values['check-runtime']) { process.stdout.write('ok'); process.exit(0); } -if (mode === '--serve') { +if (values.serve) { const rl = readline.createInterface({ input: process.stdin, terminal: false }); rl.on('line', (filePath) => { @@ -178,7 +184,7 @@ if (mode === '--serve') { process.exitCode = 0; } else { - const filePath = mode; + const [filePath] = positionals; if (!filePath) { process.stderr.write('Missing file path argument.\n');