1 | <?php |
||||
2 | |||||
3 | namespace Printful\GettextCms; |
||||
4 | |||||
5 | use Gettext\Extractors\ExtractorInterface; |
||||
6 | use Gettext\Extractors\ExtractorMultiInterface; |
||||
7 | use Gettext\Extractors\JsCode; |
||||
8 | use Gettext\Extractors\PhpCode; |
||||
9 | use Gettext\Extractors\VueJs; |
||||
10 | use Gettext\Translations; |
||||
11 | use League\Flysystem\Adapter\Local; |
||||
12 | use League\Flysystem\Filesystem; |
||||
13 | use League\Flysystem\Plugin\ListFiles; |
||||
14 | use Printful\GettextCms\Exceptions\GettextCmsException; |
||||
15 | use Printful\GettextCms\Exceptions\InvalidPathException; |
||||
16 | use Printful\GettextCms\Exceptions\UnknownExtractorException; |
||||
17 | use Printful\GettextCms\Interfaces\MessageConfigInterface; |
||||
18 | use Printful\GettextCms\Structures\ScanItem; |
||||
19 | |||||
20 | /** |
||||
21 | * Class extracts gettext function calls from source files and converts them to Translation objects |
||||
22 | */ |
||||
23 | class MessageExtractor |
||||
24 | { |
||||
25 | const EXTRACTORS = [ |
||||
26 | 'js' => JsCode::class, |
||||
27 | 'vue' => VueJs::class, |
||||
28 | 'php' => PhpCode::class, |
||||
29 | ]; |
||||
30 | |||||
31 | /** @var MessageConfigInterface */ |
||||
32 | private $config; |
||||
33 | |||||
34 | /** |
||||
35 | * @param MessageConfigInterface $config |
||||
36 | 17 | */ |
|||
37 | public function __construct(MessageConfigInterface $config) |
||||
38 | 17 | { |
|||
39 | 17 | $this->config = $config; |
|||
40 | } |
||||
41 | |||||
42 | /** |
||||
43 | * @param ScanItem[] $items |
||||
44 | * @param array|null $domains Force domains to scan. If null, will scan default domains. |
||||
45 | * @return Translations[] List of translations for each domain |
||||
46 | * @throws GettextCmsException |
||||
47 | 11 | */ |
|||
48 | public function extract(array $items, array $domains = null): array |
||||
49 | 11 | { |
|||
50 | $defaultDomain = $this->config->getDefaultDomain(); |
||||
51 | 11 | ||||
52 | 9 | if (!$domains) { |
|||
53 | 9 | $domains = $this->config->getOtherDomains(); |
|||
54 | $domains[] = $defaultDomain; |
||||
55 | } |
||||
56 | 11 | ||||
57 | /** @var Translations[] $allTranslations [domain => translations, ...] */ |
||||
58 | 11 | $allTranslations = array_reduce($domains, function (&$carry, string $domain) use ($defaultDomain) { |
|||
59 | 11 | $translations = new Translations(); |
|||
60 | |||||
61 | // When we scan for default domain, we have to specify an empty value |
||||
62 | // otherwise we would search for domain function calls with this domain |
||||
63 | // For example, empty domain will find string like __("message") |
||||
64 | // But if a domain is specified, it will look for dgettext("custom-domain", "message") |
||||
65 | 11 | if ($domain !== $defaultDomain) { |
|||
66 | 6 | $translations->setDomain($domain); |
|||
67 | } |
||||
68 | |||||
69 | 11 | $carry[$domain] = $translations; |
|||
70 | |||||
71 | 11 | return $carry; |
|||
72 | }, []); |
||||
73 | |||||
74 | foreach ($items as $item) { |
||||
75 | 10 | // Scan for this item, translations will be merged with all domain translations |
|||
76 | $this->extractForDomains($item, array_values($allTranslations)); |
||||
77 | 10 | } |
|||
78 | |||||
79 | // Always set the domain even if it is the default one |
||||
80 | 10 | // This is needed because default domain won't be set for the translations instance |
|||
81 | foreach ($allTranslations as $domain => $translations) { |
||||
82 | $translations->setDomain($domain); |
||||
83 | } |
||||
84 | |||||
85 | return array_values($allTranslations); |
||||
86 | } |
||||
87 | |||||
88 | /** |
||||
89 | * @param ScanItem $scanItem |
||||
90 | 11 | * @param Translations[] $translations |
|||
91 | * @return Translations[] |
||||
92 | 11 | * @throws InvalidPathException |
|||
93 | * @throws UnknownExtractorException |
||||
94 | 11 | */ |
|||
95 | private function extractForDomains(ScanItem $scanItem, array $translations): array |
||||
96 | 11 | { |
|||
97 | $pathnames = $this->resolvePathnames($scanItem); |
||||
98 | |||||
99 | 10 | foreach ($pathnames as $pathname) { |
|||
100 | // Translations will be merged with given object |
||||
101 | $this->extractFileMessages($pathname, $translations, $scanItem->functions); |
||||
102 | } |
||||
103 | |||||
104 | return $translations; |
||||
105 | } |
||||
106 | |||||
107 | /** |
||||
108 | 11 | * @param $pathname |
|||
109 | * @param Translations[] $translations |
||||
110 | 11 | * @param array|null $functions Optional functions to scan for |
|||
111 | * @throws UnknownExtractorException |
||||
112 | */ |
||||
113 | 10 | private function extractFileMessages($pathname, array $translations, array $functions = null) |
|||
114 | 10 | { |
|||
115 | $extractor = $this->getExtractor($pathname); |
||||
116 | |||||
117 | 10 | $options = [ |
|||
118 | 2 | 'extractComments' => '', // This extracts comments above function call |
|||
119 | // HTML attribute prefixes we parse as JS which could contain translations (are JS expressions) |
||||
120 | 'attributePrefixes' => [ |
||||
121 | 10 | ':', |
|||
122 | 10 | 'v-', |
|||
123 | ], |
||||
124 | ]; |
||||
125 | |||||
126 | if ($functions) { |
||||
127 | $options['functions'] = $functions; |
||||
128 | } |
||||
129 | |||||
130 | $extractor::fromFileMultiple($pathname, $translations, $options); |
||||
131 | 16 | } |
|||
132 | |||||
133 | 16 | /** |
|||
134 | 12 | * Returns a list of files that match this item (if single file, then an array with a single pathname) |
|||
135 | * |
||||
136 | * @param ScanItem $item |
||||
137 | 4 | * @return array List of matching pathnames |
|||
138 | 1 | * @throws InvalidPathException |
|||
139 | */ |
||||
140 | public function resolvePathnames(ScanItem $item): array |
||||
141 | 3 | { |
|||
142 | if (is_file($item->path)) { |
||||
143 | return [$item->path]; |
||||
144 | } |
||||
145 | |||||
146 | if (!is_dir($item->path)) { |
||||
147 | throw new InvalidPathException('Path "' . $item->path . '" does not exist'); |
||||
148 | } |
||||
149 | |||||
150 | 3 | return $this->resolveDirectoryFiles($item); |
|||
151 | } |
||||
152 | 3 | ||||
153 | /** |
||||
154 | 3 | * If scan item is for a directory, this will create a list of matching files |
|||
155 | 3 | * |
|||
156 | 3 | * @param ScanItem $item |
|||
157 | * @return string[] List of pathnames to files |
||||
158 | 3 | */ |
|||
159 | private function resolveDirectoryFiles(ScanItem $item): array |
||||
160 | { |
||||
161 | 3 | $dir = realpath($item->path); |
|||
162 | |||||
163 | $adapter = new Local($dir); |
||||
164 | 3 | $filesystem = new Filesystem($adapter); |
|||
165 | 3 | $filesystem->addPlugin(new ListFiles()); |
|||
166 | 3 | ||||
167 | $files = $filesystem->listFiles('', $item->recursive); |
||||
168 | 3 | ||||
169 | 3 | // If no extensions are given, fallback to known extensions |
|||
170 | 3 | $extensions = $item->extensions ?: array_keys(self::EXTRACTORS); |
|||
171 | |||||
172 | // If extensions are set, filter other files out |
||||
173 | $files = array_filter($files, function ($file) use ($item, $extensions) { |
||||
0 ignored issues
–
show
|
|||||
174 | return isset($file['extension']) && in_array($file['extension'], $extensions); |
||||
175 | }); |
||||
176 | |||||
177 | return array_map(function ($file) use ($dir) { |
||||
178 | 11 | return $dir . DIRECTORY_SEPARATOR . $file['path']; |
|||
179 | }, $files); |
||||
180 | 11 | } |
|||
181 | |||||
182 | 11 | /** |
|||
183 | 10 | * @param string $pathname Full path to file |
|||
184 | * @return ExtractorInterface|ExtractorMultiInterface|string Name of the extraction class for the given file |
||||
185 | * @throws UnknownExtractorException |
||||
186 | 1 | */ |
|||
187 | private function getExtractor($pathname): string |
||||
188 | { |
||||
189 | $extension = pathinfo($pathname, PATHINFO_EXTENSION); |
||||
190 | |||||
191 | if (isset(self::EXTRACTORS[$extension])) { |
||||
192 | return self::EXTRACTORS[$extension]; |
||||
193 | } |
||||
194 | |||||
195 | throw new UnknownExtractorException('Extractor is not know for file extension "' . $extension . '"'); |
||||
0 ignored issues
–
show
Are you sure
$extension of type array|string can be used in concatenation ?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
196 | } |
||||
197 | } |
This check looks for imports that have been defined, but are not used in the scope.