diff --git a/src/Command/TwigExtractCommand.php b/src/Command/TwigExtractCommand.php
new file mode 100644
index 0000000..e91dc65
--- /dev/null
+++ b/src/Command/TwigExtractCommand.php
@@ -0,0 +1,380 @@
+helper('Progress');
+ assert($progress instanceof ProgressHelper);
+ $progress->init(['total' => count($this->_files)]);
+ $isVerbose = $args->getOption('verbose');
+
+ $functions = [
+ '__' => ['singular'],
+ '__n' => ['singular', 'plural'],
+ '__d' => ['domain', 'singular'],
+ '__dn' => ['domain', 'singular', 'plural'],
+ '__x' => ['context', 'singular'],
+ '__xn' => ['context', 'singular', 'plural'],
+ '__dx' => ['domain', 'context', 'singular'],
+ '__dxn' => ['domain', 'context', 'singular', 'plural'],
+ ];
+ $pattern = '/(' . implode('|', array_keys($functions)) . ')\s*\(/';
+
+ foreach ($this->_files as $file) {
+ $this->_file = $file;
+ if ($isVerbose) {
+ $io->verbose(sprintf('Processing %s...', $file));
+ }
+ if (pathinfo($file, PATHINFO_EXTENSION) === 'twig') {
+ if ($this->_isTwigUsable()) {
+ $_parser = 'twig';
+ } else {
+ $io->warning('Twig is not installed. Please install Twig to extract translations from twig templates.');
+ continue;
+ }
+ $_parser = 'twig';
+ } else {
+ $_parser = 'php';
+ }
+ $code = (string)file_get_contents($file);
+
+ if (preg_match($pattern, $code) === 1) {
+ if ($_parser === 'twig') {
+ $this->_tokenizeAsTwig($code, $file);
+ } else {
+ $this->_tokenizeAsPHP($code, $file);
+ }
+
+ foreach ($functions as $functionName => $map) {
+ if ($_parser === 'twig') {
+ $this->_parseAsTwig($io, $functionName, $map);
+ } else {
+ $this->_parseAsPHP($io, $functionName, $map);
+ }
+ }
+ }
+ if ($_parser === 'php') {
+ $this->extractFileReflection($file, $code);
+ }
+
+ if (!$isVerbose) {
+ $progress->increment(1);
+ $progress->draw();
+ }
+ }
+ }
+
+ /**
+ * Parse tokens
+ *
+ * @param \Cake\Console\ConsoleIo $io The io instance
+ * @param string $functionName Function name that indicates translatable string (e.g: '__')
+ * @param array $map Array containing what variables it will find (e.g: domain, singular, plural)
+ * @return void
+ */
+ protected function _parseAsPHP(ConsoleIo $io, string $functionName, array $map): void
+ {
+ $count = 0;
+ $tokenCount = count($this->_tokens);
+
+ while ($tokenCount - $count > 1) {
+ $countToken = $this->_tokens[$count];
+ $firstParenthesis = $this->_tokens[$count + 1];
+ if (!is_array($countToken)) {
+ $count++;
+ continue;
+ }
+
+ [$type, $string, $line] = $countToken;
+ if (($type === T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) {
+ $position = $count;
+ $depth = 0;
+
+ while (!$depth) {
+ if ($this->_tokens[$position] === '(') {
+ $depth++;
+ } elseif ($this->_tokens[$position] === ')') {
+ $depth--;
+ }
+ $position++;
+ }
+
+ $mapCount = count($map);
+ $strings = $this->_getStrings($position, $mapCount);
+
+ if ($mapCount === count($strings)) {
+ $singular = '';
+ $vars = array_combine($map, $strings);
+ extract($vars);
+ $domain ??= 'default';
+ $details = [
+ 'file' => $this->_file,
+ 'line' => $line,
+ ];
+ $details['file'] = '.' . str_replace(ROOT, '', $details['file']);
+ if (isset($plural)) {
+ $details['msgid_plural'] = $plural;
+ }
+ if (isset($context)) {
+ $details['msgctxt'] = $context;
+ }
+ $this->_addTranslation($domain, $singular, $details);
+ } else {
+ $this->_markerError($io, $this->_file, $line, $functionName, $count);
+ }
+ }
+ $count++;
+ }
+ }
+
+ /**
+ * Parse Twig tokens
+ *
+ * @param \Cake\Console\ConsoleIo $io The io instance
+ * @param string $functionName Function name that indicates translatable string (e.g: '__')
+ * @param array $map Array containing what variables it will find (e.g: domain, singular, plural)
+ * @return void
+ */
+ protected function _parseAsTwig(ConsoleIo $io, string $functionName, array $map): void
+ {
+ /** @var \Twig\Token $token */
+ foreach ($this->_tokens as $count => $token) {
+ if ($token->test(Token::NAME_TYPE, $functionName)) {
+ $singular = '';
+ switch ($functionName) {
+ case '__':
+ $singular = $this->_getStringFromToken($count, 2);
+ break;
+ case '__n':
+ $singular = $this->_getStringFromToken($count, 2);
+ $plural = $this->_getStringFromToken($count, 4);
+ break;
+ case '__d':
+ $domain = $this->_getStringFromToken($count, 2);
+ $singular = $this->_getStringFromToken($count, 4);
+ break;
+ case '__dn':
+ $domain = $this->_getStringFromToken($count, 2);
+ $singular = $this->_getStringFromToken($count, 4);
+ $plural = $this->_getStringFromToken($count, 6);
+ break;
+ case '__x':
+ $context = $this->_getStringFromToken($count, 2);
+ $singular = $this->_getStringFromToken($count, 4);
+ break;
+ case '__xn':
+ $context = $this->_getStringFromToken($count, 2);
+ $singular = $this->_getStringFromToken($count, 4);
+ $plural = $this->_getStringFromToken($count, 6);
+ break;
+ case '__dx':
+ $domain = $this->_getStringFromToken($count, 2);
+ $context = $this->_getStringFromToken($count, 4);
+ $singular = $this->_getStringFromToken($count, 6);
+ break;
+ case '__dxn':
+ $domain = $this->_getStringFromToken($count, 2);
+ $context = $this->_getStringFromToken($count, 4);
+ $singular = $this->_getStringFromToken($count, 6);
+ $plural = $this->_getStringFromToken($count, 8);
+ break;
+ }
+ $domain ??= 'default';
+ $details = [
+ 'file' => $this->_file,
+ 'line' => $token->getLine(),
+ ];
+ $details['file'] = '.' . str_replace(ROOT, '', $details['file']);
+ if (in_array('plural', $map)) {
+ if (isset($plural)) {
+ $details['msgid_plural'] = $plural;
+ } else {
+ $this->_markerError($io, $this->_file, $token->getLine(), $functionName, $token->getOffset() ?? 0);
+ continue;
+ }
+ }
+
+ if (in_array('context', $map)) {
+ if (isset($context)) {
+ $details['msgctxt'] = $context;
+ } else {
+ $this->_markerError($io, $this->_file, $token->getLine(), $functionName, $token->getOffset() ?? 0);
+ continue;
+ }
+ }
+ $this->_addTranslation($domain, $singular, $details);
+ }
+ }
+ }
+
+ /**
+ * Search files that may contain translatable strings
+ *
+ * @return void
+ */
+ protected function _searchFiles(): void
+ {
+ $pattern = false;
+ if ($this->_exclude) {
+ $exclude = [];
+ foreach ($this->_exclude as $e) {
+ if (DIRECTORY_SEPARATOR !== '\\' && !str_starts_with($e, DIRECTORY_SEPARATOR)) {
+ $e = DIRECTORY_SEPARATOR . $e;
+ }
+ $exclude[] = preg_quote($e, '/');
+ }
+ $pattern = '/' . implode('|', $exclude) . '/';
+ }
+
+ foreach ($this->_paths as $path) {
+ $path = realpath($path);
+ if ($path === false) {
+ continue;
+ }
+ $path .= DIRECTORY_SEPARATOR;
+ $fs = new Filesystem();
+ $files = $fs->findRecursive($path, '/\.php$|\.twig$/');
+ $files = array_keys(iterator_to_array($files));
+ sort($files);
+ if ($pattern) {
+ $files = preg_grep($pattern, $files, PREG_GREP_INVERT) ?: [];
+ $files = array_values($files);
+ }
+ $this->_files = array_merge($this->_files, $files);
+ }
+ $this->_files = array_unique($this->_files);
+ }
+
+ /**
+ * Checks whether the Twig templating system is available.
+ *
+ * @return bool true if Twig is autoloadable and usable, false otherwise
+ */
+ protected function _isTwigUsable(): bool
+ {
+ return class_exists(Environment::class);
+ }
+
+ /**
+ * Parses the given PHP source code for tokens, filtering out whitespace and inline HTML.
+ *
+ * @param string $code Source code of the file to parse
+ * @param string $file File name and path of the file to parse
+ * @return void
+ */
+ protected function _tokenizeAsPHP(string $code, string $file): void
+ {
+ $allTokens = token_get_all($code);
+ $this->_tokens = [];
+ foreach ($allTokens as $token) {
+ if (!is_array($token) || ($token[0] !== T_WHITESPACE && $token[0] !== T_INLINE_HTML)) {
+ $this->_tokens[] = $token;
+ }
+ }
+ unset($allTokens);
+ }
+
+ /**
+ * Parses the given Twig source code for tokens.
+ *
+ * @param string $code Source code of the file to parse
+ * @param string $file File name and path of the file to parse
+ * @return void
+ */
+ protected function _tokenizeAsTwig(string $code, string $file): void
+ {
+ $twig = new Environment(new ArrayLoader());
+ $stream = $twig->tokenize(new Source(code: $code, name: $file, path: $file));
+ $this->_tokens = [];
+ while (!$stream->isEOF()) {
+ $token = $stream->next();
+ if (! $token->test(Token::TEXT_TYPE) && ! $token->test(Token::BLOCK_END_TYPE)) {
+ $this->_tokens[] = $token;
+ }
+ }
+ unset($stream);
+ }
+
+ /**
+ * Return the string represented by a token and offset
+ *
+ * It also escapes double quotes with backslash: " -> \"
+ *
+ * @param int $position The position of the token in $this->_tokens
+ * @param int $offset The offset from that position.
+ * @return string The escaped string
+ */
+ protected function _getStringFromToken(int $position, int $offset): string
+ {
+ $string = $this->_tokens[$position + $offset]->getValue();
+
+ return str_replace('"', '\"', $string);
+ }
+
+ /**
+ * Adding this here to fix a PHP 8.2 error in the tests
+ *
+ * @return void
+ */
+ protected function extractFileReflection(string $file, string $code): void
+ {
+ parent::extractFileReflection($file, $code);
+ }
+}
diff --git a/src/Filesystem/RelativeScanner.php b/src/Filesystem/RelativeScanner.php
index dce3609..ef860da 100644
--- a/src/Filesystem/RelativeScanner.php
+++ b/src/Filesystem/RelativeScanner.php
@@ -44,7 +44,6 @@ public static function all(array $extensions): array
*
* @param string $plugin The plugin to find all templates for.
* @param array $extensions Template extensions to search
- * @return mixed
*/
public static function plugin(string $plugin, array $extensions): mixed
{
diff --git a/src/Twig/FileLoader.php b/src/Twig/FileLoader.php
index 6bc1d60..f111ba6 100644
--- a/src/Twig/FileLoader.php
+++ b/src/Twig/FileLoader.php
@@ -160,7 +160,6 @@ protected function loaderError(string $name, array $templatePaths): LoaderError
* which file exists.
*
* @param string $partial Template path excluding extension
- * @return string|null
*/
public function checkExtensions(string $partial): ?string
{
diff --git a/src/TwigViewPlugin.php b/src/TwigViewPlugin.php
index 1037f32..e846494 100644
--- a/src/TwigViewPlugin.php
+++ b/src/TwigViewPlugin.php
@@ -22,6 +22,7 @@
use Cake\Core\BasePlugin;
use Cake\Core\Configure;
use Cake\TwigView\Command\CompileCommand;
+use Cake\TwigView\Command\TwigExtractCommand;
/**
* Plugin class for Cake\TwigView.
@@ -49,6 +50,7 @@ public function console(CommandCollection $commands): CommandCollection
// Deprecated: use `'TwigView.useUnderscoreCommands' => true` to switch to `twig_view compile`
$commands->add('twig-view compile', CompileCommand::class);
}
+ $commands->add('i18n extract', TwigExtractCommand::class);
return $commands;
}
diff --git a/src/View/TwigView.php b/src/View/TwigView.php
index cc22e43..af1e9d1 100644
--- a/src/View/TwigView.php
+++ b/src/View/TwigView.php
@@ -127,8 +127,6 @@ public function getTwig(): Environment
/**
* Gets Twig Profile if profiler enabled.
- *
- * @return \Twig\Profiler\Profile|null
*/
public function getProfile(): ?Profile
{
@@ -241,7 +239,6 @@ public function __construct(MarkdownInterface $engine)
/**
* @param string $class FQCN
- * @return object|null
*/
public function load(string $class): ?object
{
diff --git a/tests/Fixture/AuthorsFixture.php b/tests/Fixture/AuthorsFixture.php
index 78dd420..40f1eae 100644
--- a/tests/Fixture/AuthorsFixture.php
+++ b/tests/Fixture/AuthorsFixture.php
@@ -27,10 +27,8 @@ class AuthorsFixture extends TestFixture
{
/**
* fields property
- *
- * @var array
*/
- public $fields = [
+ public array $fields = [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string', 'default' => null],
'_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]],
@@ -38,10 +36,8 @@ class AuthorsFixture extends TestFixture
/**
* records property
- *
- * @var array
*/
- public $records = [
+ public array $records = [
['name' => 'mariano'],
['name' => 'nate'],
['name' => 'larry'],
diff --git a/tests/TestCase/Command/I18nExtractCommandTest.php b/tests/TestCase/Command/I18nExtractCommandTest.php
new file mode 100644
index 0000000..a3acd59
--- /dev/null
+++ b/tests/TestCase/Command/I18nExtractCommandTest.php
@@ -0,0 +1,444 @@
+path = TMP . 'tests/extract_task_test';
+ $fs = new Filesystem();
+ $fs->deleteDir($this->path);
+ $fs->mkdir($this->path . DS . 'locale');
+
+ Router::reload();
+ Configure::write('App.encoding', 'UTF-8');
+
+ $this->loadPlugins(['Cake/TwigView']);
+ $this->setAppNamespace();
+ }
+
+ /**
+ * tearDown method
+ */
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+
+ $fs = new Filesystem();
+ $fs->deleteDir($this->path);
+ $this->clearPlugins();
+ }
+
+ /**
+ * testExecute method
+ */
+ public function testExecute(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--merge=no ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'templates' . DS . 'i18n ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $this->assertFileExists($this->path . DS . 'test.pot');
+ $this->assertFileDoesNotExist($this->path . DS . 'cake.pot');
+
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ // The additional "./tests/test_app" is just due to the wonky folder structure of the test app.
+ // In a regular app the path would start with "./templates".
+
+ // no_domain
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "no_domain"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // no_domain, var
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "no_domain_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // no_domain, var, context
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgctxt "Context"\n';
+ $pattern .= 'msgid "no_domain_with_context_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // no_domain, plural
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "no_domain_singular"\nmsgid_plural "no_domain_plural"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // no_domain, plural, context
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgctxt "Context"\n';
+ $pattern .= 'msgid "no_domain_singular_with_context"\nmsgid_plural "no_domain_plural_with_context"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // no_domain, plural, var
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "no_domain_singular_with_\{var\}"\nmsgid_plural "no_domain_plural_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result, 'No duplicate msgid');
+
+ // no_domain, plural, var, context
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgctxt "Context"\n';
+ $pattern .= 'msgid "no_domain_singular_with_context_with_\{var\}"\nmsgid_plural "no_domain_plural_with_context_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result, 'No duplicate msgid');
+
+ $this->assertStringContainsString('msgid "double \\"quoted\\""', $result, 'Strings with quotes not handled correctly');
+ $this->assertStringContainsString("msgid \"single 'quoted'\"", $result, 'Strings with quotes not handled correctly');
+
+ // test.pot
+ $result = file_get_contents($this->path . DS . 'test.pot');
+
+ // test_domain
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "test_domain"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // test_domain, var
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "test_domain_with_{var}"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // test_domain, var, context
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgctxt "Context"\n';
+ $pattern .= 'msgid "test_domain_with_context_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // test_domain, plural
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "test_domain_singular"\nmsgid_plural "test_domain_plural"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // test_domain, plural, context
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgctxt "Context"\n';
+ $pattern .= 'msgid "test_domain_singular_with_context"\nmsgid_plural "test_domain_plural_with_context"@';
+ $this->assertMatchesRegularExpression($pattern, $result);
+
+ // test_domain, plural, var
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgid "test_domain_singular_with_\{var\}"\nmsgid_plural "test_domain_plural_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result, 'No duplicate msgid');
+
+ // test_domain, plural, var, context
+ $pattern = '@(\#: \./tests/test_app/templates/i18n/i18n_test\.twig:\d+\n)+';
+ $pattern .= 'msgctxt "Context"\n';
+ $pattern .= 'msgid "test_domain_singular_with_context_with_\{var\}"\nmsgid_plural "test_domain_plural_with_context_with_\{var\}"@';
+ $this->assertMatchesRegularExpression($pattern, $result, 'No duplicate msgid');
+
+ $this->assertStringContainsString('msgid "double \\"quoted\\""', $result, 'Strings with quotes not handled correctly');
+ $this->assertStringContainsString("msgid \"single 'quoted'\"", $result, 'Strings with quotes not handled correctly');
+ }
+
+ /**
+ * testExecute with no paths
+ */
+ public function testExecuteNoOutputOption(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--merge=no ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'templates' . DS . 'i18n ',
+ [
+ $this->path,
+ TEST_APP . 'templates' . DS . 'i18n' . DS,
+ 'D',
+ ],
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ }
+
+ /**
+ * testExecute with merging on method
+ */
+ public function testExecuteMerge(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--merge=yes ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'templates' . DS . 'i18n ' .
+ '--output=' . $this->path . DS,
+ [
+ $this->path,
+ ],
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $this->assertFileDoesNotExist($this->path . DS . 'cake.pot');
+ $this->assertFileDoesNotExist($this->path . DS . 'domain.pot');
+ }
+
+ /**
+ * test exclusions
+ */
+ public function testExtractWithExclude(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=no ' .
+ '--exclude=Pages,Layout ' .
+ '--paths=' . TEST_APP . 'templates' . DS . ' ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $pattern = '/\#: .*extract\.php:\d+\n/';
+ $this->assertDoesNotMatchRegularExpression($pattern, $result);
+
+ $pattern = '/\#: .*default\.php:\d+\n/';
+ $this->assertDoesNotMatchRegularExpression($pattern, $result);
+ }
+
+ /**
+ * testExtractWithoutLocations method
+ */
+ public function testExtractWithoutLocations(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=no ' .
+ '--no-location=true ' .
+ '--exclude=Pages,Layout ' .
+ '--paths=' . TEST_APP . 'templates' . DS . ' ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $pattern = '/\n\#: .*\n/';
+ $this->assertDoesNotMatchRegularExpression($pattern, $result);
+ }
+
+ /**
+ * test extract can read more than one path.
+ */
+ public function testExtractMultiplePaths(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=no ' .
+ '--exclude=Pages,Layout ' .
+ '--paths=' . TEST_APP . 'templates/Pages,' .
+ TEST_APP . 'templates/Posts,' .
+ TEST_APP . 'templates/i18n ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $pattern = '/msgid "Add User"/';
+ $this->assertMatchesRegularExpression($pattern, $result);
+ }
+
+ /**
+ * Test that the extract shell overwrites existing files with the overwrite parameter
+ */
+ public function testExtractOverwrite(): void
+ {
+ file_put_contents($this->path . DS . 'default.pot', 'will be overwritten');
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $original = file_get_contents($this->path . DS . 'default.pot');
+
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=no ' .
+ '--overwrite ' .
+ '--paths=' . TEST_APP . 'templates/ ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+
+ $result = file_get_contents($this->path . DS . 'default.pot');
+ $this->assertNotEquals($original, $result);
+ }
+
+ /**
+ * Test that the extract shell scans the core libs
+ */
+ public function testExtractCore(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=yes ' .
+ '--paths=' . TEST_APP . '/ ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertNotNull($this->_err);
+ $this->assertEmpty($this->_err->messages(), 'Should not have output to stderr');
+ $this->assertExitSuccess();
+
+ $this->assertFileExists($this->path . DS . 'cake.pot');
+ $result = file_get_contents($this->path . DS . 'cake.pot');
+ $this->assertTrue(is_string($result));
+
+ $pattern = '/#: Console\/Templates\//';
+ $this->assertDoesNotMatchRegularExpression($pattern, $result);
+
+ $pattern = '/#: Test\//';
+ $this->assertDoesNotMatchRegularExpression($pattern, $result);
+ }
+
+ /**
+ * Test when marker-error option is set
+ * When marker-error is unset, it's already test
+ * with other functions like testExecute that not detects error because err never called
+ */
+ public function testMarkerErrorSets(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--marker-error ' .
+ '--merge=no ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'templates/Pages ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertErrorContains('Invalid marker content in');
+ $this->assertErrorContains('extract.php');
+ }
+
+ /**
+ * Test extraction of Label attribute strings from enum cases.
+ */
+ public function testExtractLabelAttributes(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--merge=no ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'src/Model/Enum ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $this->assertStringContainsString('msgid "Published"', $result);
+ $this->assertStringContainsString('msgid "Unpublished"', $result);
+
+ $pattern = '/msgctxt "article_status"\nmsgid "Archived"/';
+ $this->assertMatchesRegularExpression($pattern, $result);
+ }
+
+ /**
+ * test relative-paths option
+ */
+ public function testExtractWithRelativePaths(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'templates ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $expected = '#: ./tests/test_app/templates/Pages/extract.php:';
+ $this->assertStringContainsString($expected, $result);
+ }
+
+ /**
+ * test invalid path options
+ */
+ public function testExtractWithInvalidPaths(): void
+ {
+ $this->exec(
+ 'i18n extract ' .
+ '--extract-core=no ' .
+ '--paths=' . TEST_APP . 'templates,' . TEST_APP . 'unknown ' .
+ '--output=' . $this->path . DS,
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $expected = '#: ./tests/test_app/templates/Pages/extract.php:';
+ $this->assertStringContainsString($expected, $result);
+ }
+
+ /**
+ * Test with associative arrays in App.path.locales and App.path.templates.
+ */
+ public function testExtractWithAssociativePaths(): void
+ {
+ Configure::write('App.paths', [
+ 'plugins' => ['customKey' => TEST_APP . 'plugins' . DS],
+ 'templates' => ['customKey' => TEST_APP . 'templates' . DS],
+ 'locales' => ['customKey' => TEST_APP . 'resources' . DS . 'locales' . DS],
+ ]);
+
+ $this->exec(
+ 'i18n extract ' .
+ '--merge=no ' .
+ '--extract-core=no ',
+ [
+ // Sending two empty inputs so \Cake\Command\I18nExtractCommand::_getPaths()
+ // loops through all paths
+ $this->path,
+ '',
+ 'D',
+ $this->path . DS,
+ ],
+ );
+ $this->assertExitSuccess();
+ $this->assertFileExists($this->path . DS . 'default.pot');
+ $result = file_get_contents($this->path . DS . 'default.pot');
+
+ $expected = '#: ./tests/test_app/templates/Pages/extract.php:';
+ $this->assertStringContainsString($expected, $result);
+ }
+}
diff --git a/tests/TestCase/Twig/Extension/AbstractExtensionTest.php b/tests/TestCase/Twig/Extension/AbstractExtensionTest.php
index 9dd8cf8..c8b097b 100644
--- a/tests/TestCase/Twig/Extension/AbstractExtensionTest.php
+++ b/tests/TestCase/Twig/Extension/AbstractExtensionTest.php
@@ -38,11 +38,6 @@ protected function setUp(): void
}
}
- protected function tearDown(): void
- {
- parent::tearDown();
- }
-
public function testGetTokenParsers(): void
{
$tokenParsers = $this->extension->getTokenParsers();
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 2cd2c38..414dbef 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -29,7 +29,9 @@
require dirname(__DIR__) . '/vendor/autoload.php';
define('ROOT', dirname(__DIR__));
-define('CORE_PATH', ROOT . DS . 'vendor/cakephp/cakephp');
+define('CAKE_CORE_INCLUDE_PATH', ROOT);
+define('CORE_PATH', CAKE_CORE_INCLUDE_PATH . DS);
+define('CAKE', CORE_PATH . 'src' . DS);
define('APP', sys_get_temp_dir());
define('TMP', sys_get_temp_dir() . '/TwigViewTmp/');
define('CACHE', sys_get_temp_dir() . '/TwigViewTmp/cache/');
diff --git a/tests/test_app/config/bootstrap.php b/tests/test_app/config/bootstrap.php
index 5ff14cf..2089b0f 100644
--- a/tests/test_app/config/bootstrap.php
+++ b/tests/test_app/config/bootstrap.php
@@ -1,2 +1,4 @@
+= h($message) ?>
+
+ = __d('cake', 'Error'); ?>:
+ = sprintf(
+ __d('cake', 'The requested address %s was not found on this server.'),
+ "'{$url}' "
+ ) ?>
+
+element('exception_stack_trace');
+endif;
+?>
diff --git a/tests/test_app/templates/Error/error500.php b/tests/test_app/templates/Error/error500.php
new file mode 100644
index 0000000..7ac7e03
--- /dev/null
+++ b/tests/test_app/templates/Error/error500.php
@@ -0,0 +1,29 @@
+
+= __d('cake', 'An Internal Error Has Occurred.') ?>
+
+ = __d('cake', 'Error') ?>:
+ = h($message) ?>
+
+element('auto_table_warning');
+ echo $this->element('exception_stack_trace');
+endif;
+?>
diff --git a/tests/test_app/templates/Pages/extract.php b/tests/test_app/templates/Pages/extract.php
new file mode 100644
index 0000000..a242540
--- /dev/null
+++ b/tests/test_app/templates/Pages/extract.php
@@ -0,0 +1,37 @@
+ 10];
+
+// Plural
+echo __n('You have %d new message.', 'You have %d new messages.', $count);
+echo __n('You deleted %d message.', 'You deleted %d messages.', $messages['count']);
+
+// Domain Plural
+echo __dn('domain', 'You have %d new message (domain).', 'You have %d new messages (domain).', '10');
+echo __dn('domain', 'You deleted %d message (domain).', 'You deleted %d messages (domain).', $messages['count']);
+
+// Duplicated Message
+echo __('Editing this Page');
+echo __('You have %d new message.');
+
+// Contains quotes
+echo __('double "quoted"');
+echo __("single 'quoted'");
+
+// Contains no string like a variable or a function or ...
+echo __($count);
+
+// Multiline
+__('Hot features!'
+ . "\n - No Configuration:"
+ . ' Set-up the database and let the magic begin'
+ . "\n - Extremely Simple:"
+ . " Just look at the name...It's Cake"
+ . "\n - Active, Friendly Community:"
+ . " Join us #cakephp on IRC. We'd love to help you get started");
+
+// Context
+echo __x('mail', 'letter');
+
+// Duplicated message with different context
+echo __x('alphabet', 'letter');
diff --git a/tests/test_app/templates/Pages/home.php b/tests/test_app/templates/Pages/home.php
new file mode 100644
index 0000000..0224078
--- /dev/null
+++ b/tests/test_app/templates/Pages/home.php
@@ -0,0 +1,169 @@
+
+= sprintf('Release Notes for CakePHP %s.', Configure::version()); ?>
+
+ Read the changelog
+
+
+
+
+ URL rewriting is not properly configured on your server.
+ 1) Help me configure it
+ 2) I don't / can't use URL rewriting
+
+
+
+
+ Your version of PHP is 5.4.3 or higher
+
+
+
+
+ Your version of PHP has mbstring extension loaded.
+
+ Your version of PHP does NOT have the mbstring extension loaded.
+
+
+
+
+
+ Your tmp directory is writable.
+
+ Your tmp directory is NOT writable.
+
+
+
+
+config() : false;
+if (!empty($settings)): ?>
+ The = $settings['engine'] ?>Engine is being used for core caching. To change the config edit APP/Config/cache.php
+
+ Your cache is NOT working. Please check the settings in APP/Config/cache.php
+
+
+
+
+
+ Your datasources configuration file is present.
+
+
+ Your datasources configuration file is NOT present.
+
+ Rename APP/Config/datasources.default.php to APP/Config/datasources.php
+
+
+
+
+
+ '
+ PCRE has not been compiled with Unicode support.';
+
+ Recompile PCRE with Unicode support by adding --enable-unicode-properties when configuring
+
+
+
+
+
+ DebugKit plugin is present
+
+ ';
+ DebugKit is not installed. It will help you inspect and debug different aspects of your application.
+
+ You can install it from = $this->Html->link('GitHub', 'https://github.com/cakephp/debug_kit'); ?>
+
+
+
+
+Editing this Page
+
+To change the content of this page, edit: APP/View/Pages/home.ctp.
+To change its layout, edit: APP/View/Layout/default.ctp.
+You can also add some CSS styles for your pages at: APP/webroot/css.;
+
+
+Getting Started
+
+ Html->link(
+ 'New CakePHP Docs',
+ 'https://book.cakephp.org/5/en/',
+ ['target' => '_blank', 'escape' => false]
+ );
+ ?>
+
+
+ Html->link(
+ 'The 15 min Blog Tutorial',
+ 'https://book.cakephp.org/5/en/getting-started.html#blog-tutorial',
+ ['target' => '_blank', 'escape' => false]
+ );
+ ?>
+
+
+Official Plugins
+
+
+
+ = $this->Html->link('DebugKit', 'https://github.com/cakephp/debug_kit') ?>:
+ provides a debugging toolbar and enhanced debugging tools for CakePHP application.
+
+
+ = $this->Html->link('Localized', 'https://github.com/cakephp/localized') ?>:
+ contains various localized validation classes and translations for specific countries
+
+
+
+
+More about CakePHP
+
+CakePHP is a rapid development framework for PHP which uses commonly known design patterns like Active Record, Association Data Mapping, Front Controller and MVC.
+
+
+Our primary goal is to provide a structured framework that enables PHP users at all levels to rapidly develop robust web applications, without any loss to flexibility.
+
+
+
diff --git a/tests/test_app/templates/Posts/cache_form.php b/tests/test_app/templates/Posts/cache_form.php
new file mode 100644
index 0000000..7100210
--- /dev/null
+++ b/tests/test_app/templates/Posts/cache_form.php
@@ -0,0 +1,11 @@
+
+
+ = $this->Form->create(); ?>
+
+ = __('Add User'); ?>
+
+ = $this->Form->submit('Submit'); ?>
+ = $this->Form->end(); ?>
+
diff --git a/tests/test_app/templates/i18n/i18n_test.twig b/tests/test_app/templates/i18n/i18n_test.twig
new file mode 100644
index 0000000..6c8d8ab
--- /dev/null
+++ b/tests/test_app/templates/i18n/i18n_test.twig
@@ -0,0 +1,79 @@
+{{ __('no_domain') }}
+{{ __('no_domain_with_{var}', {var: 'var'}) }}
+{{ __n('no_domain_singular', 'no_domain_plural', 2) }}
+{{ __n('no_domain_singular_with_{var}', 'no_domain_plural_with_{var}', 2, {var: 'var'}) }}
+{{ __x('Context', 'no_domain_with_context') }}
+{{ __x('Context', 'no_domain_with_context_with_{var}') }}
+{{ __xn('Context','no_domain_singular_with_context', 'no_domain_plural_with_context', 2) }}
+{{ __xn('Context','no_domain_singular_with_context_with_{var}', 'no_domain_plural_with_context_with_{var}', 2, {var: 'var'}) }}
+{# test_domain #}
+{{ __d('test', 'test_domain') }}
+{{ __d('test', 'test_domain_with_{var}', {var: 'var'}) }}
+{{ __dn('test', 'test_domain_singular', 'test_domain_plural', 2) }}
+{{ __dn('test',
+ 'test_domain_singular_with_{var}',
+ 'test_domain_plural_with_{var}', 2, {var: 'var'},
+) }}
+{{ __dx('test', 'Context', 'test_domain_with_context') }}
+{{ __dx('test', 'Context', 'test_domain_with_context_with_{var}', {var: 'var'}) }}
+{{ __dxn('test', 'Context',
+ 'test_domain_singular_with_context',
+ 'test_domain_plural_with_context', 2) }}
+{{ __dxn('test', 'Context',
+ 'test_domain_singular_with_context_with_{var}',
+ 'test_domain_plural_with_context_with_{var}',
+ 2,
+ {var: 'var'},
+) }}
+
+{# multiline #}
+{{ __('no_domain' ~
+ '_multiline') }}
+{{ __('no_domain' ~
+ '_multiline_with_{var}', {var: 'var'}) }}
+{{ __n('no_domain' ~
+ '_multiline_singular', 'no_domain' ~
+ '_multiline_plural', 2) }}
+{{ __n('no_domain' ~
+ '_multiline_singular_with_{var}', 'no_domain' ~
+ '_multiline_plural_with_{var}', 2, {var: 'var'}) }}
+{{ __x('Context', 'no_domain' ~
+ '_multiline_with_context') }}
+{{ __x('Context', 'no_domain' ~
+ '_multiline_with_context_with_{var}') }}
+{{ __xn('Context', 'no_domain' ~
+ '_multiline_singular', 'no_domain' ~
+ '_multiline_plural', 2) }}
+{{ __xn('Context', 'no_domain' ~
+ '_multiline_singular_with_{var}', 'no_domain' ~
+ '_multiline_plural_with_{var}', 2, {var: 'var'}) }}
+{# multiline test_domain #}
+{{ __d('test', 'test_domain' ~
+ '_multiline') }}
+{{ __d('test', 'test_domain' ~
+ '_multiline_with_{var}', {var: 'var'}) }}
+{{ __dn('test', 'test_domain' ~
+ '_multiline_singular', 'test_domain' ~
+ '_multiline_plural', 2) }}
+{{ __dn('test',
+ 'test_domain' ~
+ '_multiline _singular_with_{var}',
+ 'test_domain' ~
+ '_multiline_plural_with_{var}', 2, {var: 'var'},
+) }}
+{{ __dx('test', 'Context', 'test_domain' ~
+ '_multiline_with_context') }}
+{{ __dx('test', 'Context', 'test_domain' ~
+ '_multiline_with_context_with_context_with_{var}', {var: 'var'}) }}
+{{ __dxn('test', 'Context', 'test_domain' ~
+ '_multiline_with_context_singular', 'test_domain' ~
+ '_multiline_with_context_plural', 2) }}
+{{ __dxn('test', 'Context', 'test_domain' ~
+ '_multiline_singular_with_context_with_{var}', 'test_domain' ~
+ '_multiline_plural_with_context_with_{var}', 2, {var: 'var'}) }}
+{# Contains quotes #}
+{{ __('double "quoted"')}}
+{{ __("single 'quoted'")}}
+{# Contains quotes #}
+{{ __d('test', 'double "quoted"')}}
+{{ __d('test', "single 'quoted'")}}