diff --git a/.github/workflows/backend-ci.yaml b/.github/workflows/backend-ci.yaml index ee9027660b..8808eee9b7 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: '24' + + - 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: '24' + + - 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: '24' + + - name: Install frontend dependencies + run: yarn install + - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 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..d538875d66 100644 --- a/src/bundle/Resources/config/services/translation.yaml +++ b/src/bundle/Resources/config/services/translation.yaml @@ -8,6 +8,12 @@ services: tags: - { name: jms_translation.file_visitor } + Ibexa\AdminUi\Translation\Extractor\TypeScriptFileVisitor: + 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..4b2b5e433b --- /dev/null +++ b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php @@ -0,0 +1,193 @@ +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->readExtractionResponse(); + } + + private function assertRuntimeIsReady(): void + { + if ($this->isRuntimeReady) { + return; + } + + if (!is_file($this->parserScriptPath)) { + throw new RuntimeException(sprintf( + 'TypeScript translation extractor script not found: %s.', + $this->parserScriptPath, + )); + } + + $runtimeCheckProcess = new Process([ + $this->nodeBinary, + $this->parserScriptPath, + '--check-runtime', + ]); + $runtimeCheckProcess->run(); + + 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($runtimeCheckProcess->getErrorOutput()), + )); + } + + $this->isRuntimeReady = true; + } + + private function ensureServerStarted(): void + { + if ($this->serverProcess !== null && $this->serverProcess->isRunning()) { + return; + } + + $processInput = new InputStream(); + + $extractorProcess = new Process([ + $this->nodeBinary, + $this->parserScriptPath, + '--serve', + ]); + $extractorProcess->setInput($processInput); + $extractorProcess->setTimeout(null); + $extractorProcess->start(); + + $this->serverInput = $processInput; + $this->serverProcess = $extractorProcess; + $this->responseBuffer = ''; + } + + 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 readExtractionResponse(): array + { + $process = $this->getServerProcess(); + $responseDeadline = microtime(true) + $this->responseTimeoutSeconds; + + while (!str_contains($this->responseBuffer, "\n")) { + if (!$process->isRunning()) { + $this->discardServerProcess(); + + throw new RuntimeException(sprintf( + 'TypeScript translation extractor process terminated unexpectedly: %s', + trim($process->getErrorOutput()), + )); + } + + if (microtime(true) > $responseDeadline) { + $this->discardServerProcess(); + + throw new RuntimeException( + 'Timed out waiting for the TypeScript translation extractor process to respond.', + ); + } + + $this->responseBuffer .= $process->getIncrementalOutput(); + + if (!str_contains($this->responseBuffer, "\n")) { + usleep(2000); + } + } + + [$responseLine, $rest] = explode("\n", $this->responseBuffer, 2); + $this->responseBuffer = $rest; + + 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 $responseData; + } + + private function discardServerProcess(): void + { + $this->serverInput?->close(); + $this->serverProcess?->stop(3); + + $this->serverProcess = null; + $this->serverInput = null; + $this->responseBuffer = ''; + } +} diff --git a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php new file mode 100644 index 0000000000..3bb7c9ee61 --- /dev/null +++ b/src/lib/Translation/Extractor/TypeScriptFileVisitor.php @@ -0,0 +1,138 @@ +logger = new NullLogger(); + } + + public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void + { + if (!$this->supports($file)) { + return; + } + + $extractedMessages = $this->extractMessages($file); + + if ($extractedMessages === null) { + return; + } + + foreach ($extractedMessages as $messageData) { + if (!is_array($messageData) || !isset($messageData['id']) || !is_string($messageData['id'])) { + continue; + } + + $catalogue->add($this->createMessage($file, $messageData)); + } + } + + /** + * @return array|null + */ + private function extractMessages(SplFileInfo $file): ?array + { + $extractionResponse = $this->extractorClient->extract((string) $file->getRealPath()); + + if (isset($extractionResponse['error'])) { + $this->logger?->error(sprintf( + 'Unable to parse TypeScript file %s: %s', + $file->getRealPath(), + $extractionResponse['error'], + )); + + return null; + } + + if (!isset($extractionResponse['messages']) || !is_array($extractionResponse['messages'])) { + $this->logger?->error(sprintf( + 'Unable to decode TypeScript extractor output for file %s.', + $file->getRealPath(), + )); + + return null; + } + + $this->logWarnings($extractionResponse['warnings'] ?? []); + + return $extractionResponse['messages']; + } + + /** + * @param mixed $warnings + */ + private function logWarnings(mixed $warnings): void + { + if (!is_array($warnings)) { + return; + } + + foreach ($warnings as $warning) { + if (is_string($warning)) { + $this->logger?->error($warning); + } + } + } + + /** + * @param array{id: string, domain?: mixed, desc?: mixed} $messageData + */ + private function createMessage(SplFileInfo $file, array $messageData): Message + { + $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)); + + return $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 + { + $realPath = $file->getRealPath(); + + 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 new file mode 100644 index 0000000000..c63e49acc2 --- /dev/null +++ b/src/lib/Translation/Extractor/typescript_translation_extractor.mjs @@ -0,0 +1,200 @@ +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?.type !== 'CallExpression') { + return false; + } + + 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) + ); +}; + +const getStringLiteralValue = (node) => { + if (node?.type === 'Literal' && typeof node?.value === 'string') { + return node.value; + } + + return null; +}; + +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 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 ${sourceLine} column ${sourceColumn}).`; +}; + +const findClosestLeadingComment = (source, comments, 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 extractDescription = (source, comments, node) => { + const comment = findClosestLeadingComment(source, comments, 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 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 domainNode = node.arguments[domainArgIndex]; + const domain = domainNode ? getStringLiteralValue(domainNode) : null; + + if (domainNode && domain === null) { + warnings.push(formatWarning('domain', domainNode, filePath)); + } + + return { + id, + domain, + desc: extractDescription(source, comments, idNode), + }; +}; + +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 (message !== null) { + context.messages.push(message); + } + } + + for (const childNode of Object.values(node)) { + if (Array.isArray(childNode)) { + childNode.forEach((nestedNode) => visitNode(nestedNode, context)); + continue; + } + + if (childNode && typeof childNode === 'object') { + visitNode(childNode, 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: [], + }; + + visitNode(ast, context); + + return { messages: context.messages, warnings: context.warnings }; +}; + +const { values, positionals } = parseArgs({ + options: { + 'check-runtime': { type: 'boolean' }, + serve: { type: 'boolean' }, + }, + allowPositionals: true, +}); + +if (values['check-runtime']) { + process.stdout.write('ok'); + process.exit(0); +} + +if (values.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] = positionals; + + 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..c239c98148 --- /dev/null +++ b/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php @@ -0,0 +1,277 @@ + [], '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 = $this->createVisitor($scriptPath); + $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 = $this->createVisitor($scriptPath); + $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 = $this->createVisitor($scriptPath); + + $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' + createVisitor($scriptPath); + + $this->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 = $this->createVisitor($scriptPath); + + $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 createVisitor(string $scriptPath): TypeScriptFileVisitor + { + return new TypeScriptFileVisitor(new TypeScriptExtractorClient( + parserScriptPath: $scriptPath, + nodeBinary: PHP_BINARY, + )); + } + + 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..4d7af30f0d --- /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()); + + $extractionResponse = json_decode($process->getOutput(), true); + + self::assertSame($expectedMessages, $extractionResponse['messages']); + self::assertCount($expectedWarningSubstrings === [] ? 0 : count($expectedWarningSubstrings), $extractionResponse['warnings']); + + foreach ($expectedWarningSubstrings as $index => $substring) { + self::assertStringContainsString($substring, $extractionResponse['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'; + } +}