From 07488221e01609ac106f67d33d404793e75a2ae5 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 3 Aug 2026 13:32:22 +0200 Subject: [PATCH 1/2] IBX-12091: Moved translation extractors to translations package --- composer.json | 11 +- package.json | 3 +- phpstan-baseline.neon | 60 ---- .../config/services/translation.yaml | 10 - .../TypeScriptExtractorException.php | 15 - .../Extractor/JavaScriptFileVisitor.php | 211 ------------- .../Extractor/TypeScriptExtractorClient.php | 205 ------------- .../Extractor/TypeScriptFileVisitor.php | 136 --------- .../typescript_translation_extractor.mjs | 204 ------------- tests/integration/AdminUiIbexaTestKernel.php | 2 + .../Extractor/TypeScriptFileVisitorTest.php | 277 ------------------ ...peScriptTranslationExtractorScriptTest.php | 238 --------------- 12 files changed, 13 insertions(+), 1359 deletions(-) delete mode 100644 src/lib/Exception/TypeScriptExtractorException.php delete mode 100644 src/lib/Translation/Extractor/JavaScriptFileVisitor.php delete mode 100644 src/lib/Translation/Extractor/TypeScriptExtractorClient.php delete mode 100644 src/lib/Translation/Extractor/TypeScriptFileVisitor.php delete mode 100644 src/lib/Translation/Extractor/typescript_translation_extractor.mjs delete mode 100644 tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php delete mode 100644 tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php diff --git a/composer.json b/composer.json index 6e647b8019..6c8031583f 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,15 @@ "license": "(GPL-2.0-only or proprietary)", "type": "project", "description": "Ibexa Admin Ui", + "repositories": [ + { + "type": "path", + "url": "../translations", + "options": { + "symlink": true + } + } + ], "replace": { "ezsystems/ezplatform-admin-ui": "*" }, @@ -34,7 +43,6 @@ "ibexa/user": "~6.0.x-dev", "jms/translation-bundle": "^2.4", "knplabs/knp-menu-bundle": "^3.0", - "mck89/peast": "^1.9", "symfony/asset": "^7.4", "symfony/config": "^7.4", "symfony/console": "^7.4", @@ -71,6 +79,7 @@ "ibexa/rector": "~6.0.x-dev", "ibexa/test-core": "~6.0.x-dev", "ibexa/test-rest": "~6.0.x-dev", + "ibexa/translations": "~6.0.x-dev", "matthiasnoback/symfony-dependency-injection-test": "^5.0", "phpstan/phpstan": "^2.0", "phpstan/phpstan-phpunit": "^2.0", diff --git a/package.json b/package.json index 820d1c3d1d..bd45552855 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "@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", - "@typescript-eslint/typescript-estree": "^8.62.1" + "@types/react-dom": "^19.1.2" }, "scripts": { "test": "yarn prettier-test && yarn ts-test && yarn eslint-test", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 75f0628151..3696c0f233 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2604,66 +2604,6 @@ parameters: count: 1 path: src/lib/Tab/LocationView/VersionsTab.php - - - message: '#^Access to an undefined property object\:\:\$text\.$#' - identifier: property.notFound - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Call to an undefined method Peast\\Syntax\\Node\\Expression\:\:getLeadingComments\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Call to an undefined method Peast\\Syntax\\Node\\Expression\:\:getLocation\(\)\.$#' - identifier: method.notFound - count: 2 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Call to an undefined method Peast\\Syntax\\Node\\Expression\:\:getType\(\)\.$#' - identifier: method.notFound - count: 2 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Call to an undefined method Peast\\Syntax\\Node\\Expression\:\:getValue\(\)\.$#' - identifier: method.notFound - count: 2 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Method Ibexa\\AdminUi\\Translation\\Extractor\\JavaScriptFileVisitor\:\:visitPhpFile\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Method Ibexa\\AdminUi\\Translation\\Extractor\\JavaScriptFileVisitor\:\:visitPhpFile\(\) has parameter \$ast with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Method Ibexa\\AdminUi\\Translation\\Extractor\\JavaScriptFileVisitor\:\:visitTwigFile\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Parameter \#1 \$desc of method JMS\\TranslationBundle\\Model\\Message\:\:setDesc\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - - - message: '#^Parameter \#1 \$source of static method Peast\\Peast\:\:latest\(\) expects string, string\|false given\.$#' - identifier: argument.type - count: 1 - path: src/lib/Translation/Extractor/JavaScriptFileVisitor.php - - message: '#^Instanceof between string and PhpParser\\Comment\\Doc will always evaluate to false\.$#' identifier: instanceof.alwaysFalse diff --git a/src/bundle/Resources/config/services/translation.yaml b/src/bundle/Resources/config/services/translation.yaml index d538875d66..8fb8ce3121 100644 --- a/src/bundle/Resources/config/services/translation.yaml +++ b/src/bundle/Resources/config/services/translation.yaml @@ -4,16 +4,6 @@ services: autoconfigure: true public: false - Ibexa\AdminUi\Translation\Extractor\JavaScriptFileVisitor: - 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/Exception/TypeScriptExtractorException.php b/src/lib/Exception/TypeScriptExtractorException.php deleted file mode 100644 index 12b8df5f3c..0000000000 --- a/src/lib/Exception/TypeScriptExtractorException.php +++ /dev/null @@ -1,15 +0,0 @@ -logger = new NullLogger(); - - $this->docParser = new DocParser(); - $this->docParser->setIgnoreNotImportedAnnotations(true); - $this->docParser->setImports([ - 'desc' => Desc::class, - ]); - } - - public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void - { - if (!$this->supports($file)) { - return; - } - - try { - $source = file_get_contents($file->getRealPath()); - - $parser = Peast::latest($source, [ - 'comments' => true, - 'jsx' => true, - 'sourceType' => Peast::SOURCE_TYPE_MODULE, - ]); - - $ast = $parser->parse(); - } catch (Exception $e) { - $this->logger?->error(sprintf( - 'Unable to parse file %s: %s in line %d column %d', - $file->getRealPath(), - $e->getMessage(), - $e->getPosition()->getLine(), - $e->getPosition()->getColumn() - )); - - return; - } - - $ast->traverse(function ($node) use ($catalogue, $file): void { - if ($this->isMethodCall($node, self::TRANSLATOR_OBJECT, self::TRANSLATOR_TRANS_METHOD) - || $this->isMethodCall($node, self::TRANSLATOR_OBJECT, self::TRANSLATOR_TRANS_CHOICE_METHOD) - ) { - $arguments = $node->getArguments(); - $id = $this->extractId($file, $arguments); - if ($id !== null) { - $callee = $node->getCallee(); - $property = $callee->getProperty(); - - $message = new Message( - $id, - $this->extractDomain($file, $arguments, $property->getName()) ?? $this->defaultDomain - ); - $message->setDesc($this->extractDesc($arguments)); - $message->addSource(new FileSource((string)$file)); - - $catalogue->add($message); - } - } - }); - } - - public function visitPhpFile(SplFileInfo $file, MessageCatalogue $catalogue, array $ast) - { - } - - public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast) - { - } - - private function isMethodCall(Node\Node $node, string $objectName, string $methodName): bool - { - if ($node instanceof Node\CallExpression) { - $callee = $node->getCallee(); - - if ($callee instanceof Node\MemberExpression) { - $object = $callee->getObject(); - $property = $callee->getProperty(); - - if ($object instanceof Node\Identifier && $property instanceof Node\Identifier) { - return $object->getName() === $objectName && $property->getName() === $methodName; - } - } - } - - return false; - } - - /** - * Extracts a message domain from the translator call. - * - * @param \Peast\Syntax\Node\Expression[] $arguments - */ - private function extractId(SplFileInfo $file, array $arguments): ?string - { - if (!empty($arguments)) { - $idNode = $arguments[self::ID_ARG]; - - if (!($idNode instanceof Node\StringLiteral)) { - $position = $idNode->getLocation()->getStart(); - - $this->logger?->error(sprintf( - 'Could not extract id, expected string literal but got %s (in %s on line %d column %d).', - $idNode->getType(), - $file->getRealPath(), - $position->getLine(), - $position->getColumn() - )); - } - - return $idNode->getValue(); - } - - return null; - } - - /** - * Extracts a message domain from the translator call. - * - * @param \Peast\Syntax\Node\Expression[] $arguments - */ - private function extractDomain(SplFileInfo $file, array $arguments, string $methodName): ?string - { - $domainArgIndex = $methodName === self::TRANSLATOR_TRANS_METHOD - ? self::TRANS_DOMAIN_ARG - : self::TRANS_CHOICE_DOMAIN_ARG; - - if (isset($arguments[$domainArgIndex])) { - $domainNode = $arguments[$domainArgIndex]; - - if (!($domainNode instanceof Node\StringLiteral)) { - $position = $domainNode->getLocation()->getStart(); - - $this->logger?->error(sprintf( - 'Could not extract domain, expected string literal but got %s (in %s on line %d column %d).', - $domainNode->getType(), - $file->getRealPath(), - $position->getLine(), - $position->getColumn() - )); - } - - return $domainNode->getValue(); - } - - return null; - } - - /** - * Extracts a message description from the translator call. - * - * @param \Peast\Syntax\Node\Expression[] $arguments - */ - private function extractDesc(array $arguments): ?string - { - if (!empty($arguments)) { - foreach ($arguments[self::ID_ARG]->getLeadingComments() as $comment) { - $annotations = $this->docParser->parse($comment->getText()); - if (!empty($annotations)) { - return $annotations[0]->text; - } - } - } - - return null; - } - - private function supports(SplFileInfo $file): bool - { - return str_ends_with($file->getRealPath(), '.js') && !str_ends_with($file->getRealPath(), '.min.js'); - } -} diff --git a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php b/src/lib/Translation/Extractor/TypeScriptExtractorClient.php deleted file mode 100644 index d836ac69f7..0000000000 --- a/src/lib/Translation/Extractor/TypeScriptExtractorClient.php +++ /dev/null @@ -1,205 +0,0 @@ -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 TypeScriptExtractorException(sprintf( - 'TypeScript translation extractor script not found: %s.', - $this->parserScriptPath, - )); - } - - // Runs the Node script once to check it works: this fails early if Node is missing - // or the `@typescript-eslint/typescript-estree` package is not installed. Whether each - // file parses correctly is checked later in readExtractionResponse(). - $runtimeCheckProcess = new Process([ - $this->nodeBinary, - $this->parserScriptPath, - '--check-runtime', - ]); - $runtimeCheckProcess->run(); - - if (!$runtimeCheckProcess->isSuccessful()) { - throw new TypeScriptExtractorException(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 TypeScriptExtractorException('TypeScript translation extractor input stream has not been started.'); - } - - return $this->serverInput; - } - - private function getServerProcess(): Process - { - if ($this->serverProcess === null) { - throw new TypeScriptExtractorException('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 TypeScriptExtractorException(sprintf( - 'TypeScript translation extractor process terminated unexpectedly: %s', - trim($process->getErrorOutput()), - )); - } - - if (microtime(true) > $responseDeadline) { - $this->discardServerProcess(); - - throw new TypeScriptExtractorException( - '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 deleted file mode 100644 index 61da9d2d0a..0000000000 --- a/src/lib/Translation/Extractor/TypeScriptFileVisitor.php +++ /dev/null @@ -1,136 +0,0 @@ -logger = new NullLogger(); - } - - public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void - { - if (!$this->supports($file)) { - return; - } - - foreach ($this->extractMessages($file) as $messageData) { - if (!is_array($messageData) || !isset($messageData['id']) || !is_string($messageData['id'])) { - continue; - } - - $catalogue->add($this->createMessage($file, $messageData)); - } - } - - /** - * @return array - */ - 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 []; - } - - if (!isset($extractionResponse['messages']) || !is_array($extractionResponse['messages'])) { - $this->logger?->error(sprintf( - 'Unable to decode TypeScript extractor output for file %s.', - $file->getRealPath(), - )); - - return []; - } - - $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?->warning($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 - { - // Intentionally empty: this visitor only extracts messages from TypeScript files. - // PHP sources are handled by the dedicated PHP file visitors. - } - - public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast): void - { - // Intentionally empty: this visitor only extracts messages from TypeScript files. - // Twig templates are handled by the dedicated Twig file visitor. - } - - 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 deleted file mode 100644 index bc3a319cdd..0000000000 --- a/src/lib/Translation/Extractor/typescript_translation_extractor.mjs +++ /dev/null @@ -1,204 +0,0 @@ -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) => { - if (typeof filePath !== 'string' || !/\.tsx?$/.test(filePath)) { - throw new Error(`Refusing to read non-TypeScript path: ${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/integration/AdminUiIbexaTestKernel.php b/tests/integration/AdminUiIbexaTestKernel.php index 9dba54a79b..06c07292ca 100644 --- a/tests/integration/AdminUiIbexaTestKernel.php +++ b/tests/integration/AdminUiIbexaTestKernel.php @@ -18,6 +18,7 @@ use Ibexa\Bundle\Rest\IbexaRestBundle; use Ibexa\Bundle\Search\IbexaSearchBundle; use Ibexa\Bundle\Test\Rest\IbexaTestRestBundle; +use Ibexa\Bundle\Translations\IbexaTranslationsBundle; use Ibexa\Bundle\TwigComponents\IbexaTwigComponentsBundle; use Ibexa\Bundle\User\IbexaUserBundle; use Ibexa\Contracts\AdminUi\ContentType\ContentTypeFieldsByExpressionServiceInterface; @@ -54,6 +55,7 @@ public function registerBundles(): iterable yield new IbexaTestRestBundle(); yield new IbexaUserBundle(); yield new IbexaNotificationsBundle(); + yield new IbexaTranslationsBundle(); yield new TwigComponentBundle(); yield new IbexaTwigComponentsBundle(); diff --git a/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php b/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php deleted file mode 100644 index 1769d488f6..0000000000 --- a/tests/lib/Translation/Extractor/TypeScriptFileVisitorTest.php +++ /dev/null @@ -1,277 +0,0 @@ - [], '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('warning') - ->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(TypeScriptExtractorException::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 (TypeScriptExtractorException $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 deleted file mode 100644 index 4fa449bd9f..0000000000 --- a/tests/lib/Translation/Extractor/TypeScriptTranslationExtractorScriptTest.php +++ /dev/null @@ -1,238 +0,0 @@ - $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_'); - rename($goodFile, $goodFile .= '.ts'); - file_put_contents($goodFile, "Translator.trans('admin_ui.good', {}, 'ibexa_admin_ui');"); - - $badFile = tempnam(sys_get_temp_dir(), 'ts_extractor_'); - rename($badFile, $badFile .= '.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_'); - rename($file, $file .= '.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 5eda24472aa41580197aa1dec7a16aaa4fbe5f06 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 4 Aug 2026 15:16:14 +0200 Subject: [PATCH 2/2] IBX-12091: Removed temporary translations path repository --- composer.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/composer.json b/composer.json index 6c8031583f..bb5a17f1dc 100644 --- a/composer.json +++ b/composer.json @@ -3,15 +3,6 @@ "license": "(GPL-2.0-only or proprietary)", "type": "project", "description": "Ibexa Admin Ui", - "repositories": [ - { - "type": "path", - "url": "../translations", - "options": { - "symlink": true - } - } - ], "replace": { "ezsystems/ezplatform-admin-ui": "*" },