PluginProbe ʕ •ᴥ•ʔ
GPTranslate – Multilingual AI Translation Agent for WordPress: Translate Your Site with AI / 2.26
GPTranslate – Multilingual AI Translation Agent for WordPress: Translate Your Site with AI v2.26
2.34.2 2.34.1 2.34 2.33.10 2.33.9 2.33.8 2.33.7 2.33.6 2.33.5 2.33.2 2.32.10 2.33 2.33.1 2.32.6 2.32.7 2.32.8 trunk 2.10.3 2.10.4 2.10.5 2.10.6 2.11 2.12 2.13 2.14 2.14.1 2.15 2.15.1 2.16.1 2.16.2 2.17 2.18 2.18.1 2.18.2 2.19 2.20 2.21 2.22 2.23 2.24 2.25 2.25.1 2.25.2 2.26 2.27 2.27.10 2.27.5 2.28 2.28.1 2.29 2.30 2.31 2.32 2.32.5
gptranslate / serverside-translations.php
gptranslate Last commit date
assets 5 months ago flags 5 months ago language 5 months ago ajax-handler.php 5 months ago gptranslate.php 5 months ago multilang-routing.php 5 months ago readme.txt 5 months ago serverside-translations.php 5 months ago settings.php 5 months ago simplehtmldom.php 5 months ago uninstall.php 5 months ago
serverside-translations.php
1552 lines
1 <?php
2 if (!defined('ABSPATH')) exit;
3
4 // Hook to apply server-side translations before output is sent to the browser
5 add_action ( 'template_redirect', function () {
6 if (is_admin ()) {
7 return;
8 }
9
10 $raw_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
11 $uri = esc_url_raw( $raw_request_uri );
12
13 $settings = get_option ( 'gptranslate_options', [ ] );
14
15 if (empty ( $settings ['serverside_translations'] ) || $settings ['serverside_translations'] != 1)
16 return;
17
18 // Skip static assets and admin/API routes
19 if (preg_match ( '#\.(ico|png|jpe?g|gif|svg|css|js|woff2?|ttf|eot|mp4|webm)$#i', $uri ) || strpos ( $uri, '/wp-' ) === 0 || strpos ( $uri, '/wp-json/' ) === 0) {
20 return;
21 }
22
23 function normalizeText($text) {
24 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
25 $text = str_replace(["\xC2\xA0", "\n", "\r", "\t"], ' ', $text); // NBSP e spazi strani
26 $text = preg_replace('/\s+/u', ' ', $text); // spazi multipli → singolo spazio
27 $text = trim($text);
28 return $text;
29 }
30
31 // Normalizza senza trim per preservare spazi iniziali/finali (utile per SimpleHTMLDOM).
32 function normalizeTextNoTrim($text) {
33 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
34 $text = str_replace(["\xC2\xA0", "\n", "\r", "\t"], ' ', $text); // NBSP e spazi strani
35 $text = preg_replace('/\s+/u', ' ', $text); // spazi multipli -> singolo spazio
36 return $text;
37 }
38
39 ob_start ( function ($html) use ($uri, $settings) {
40 global $wpdb;
41
42 $originalLang = $settings ['language'] ?? '';
43
44 if (! defined ( 'GPTRANSLATE_CURRENT_LANG' )) {
45 return $html;
46 }
47
48 $translatedLang = GPTRANSLATE_CURRENT_LANG;
49
50 if (empty ( $originalLang ) || $originalLang === $translatedLang)
51 return $html;
52
53 if ($settings['subfolder_installation'] && ! $settings ['rewrite_language_alias']) {
54 $uri_parts = explode('/', ltrim($uri, '/'));
55
56 // Remove the first part (subfolder name)
57 $subfolder_prefix = '/' . array_shift($uri_parts);
58
59 // Rebuild URI without subfolder
60 $uri = '/' . implode('/', $uri_parts);
61 }
62
63 $pageLink = rtrim ( get_site_url (), '/' ) . '/' . ltrim ( GPTRANSLATE_CURRENT_LANG, '/' ) . $uri;
64
65 if ( $settings ['serverside_translations_urldecode'] ) {
66 $pageLink = urldecode ( $pageLink );
67 }
68
69 if ($settings ['serverside_translations_ignore_querystring'] == 1) {
70 // Remove query string
71 $pageLink = strtok ( $pageLink, '?' );
72 } elseif (! empty ( $_SERVER ['QUERY_STRING'] )) {
73 // Sanitize and rebuild query string
74 $raw_qs = isset($_SERVER['QUERY_STRING']) ? wp_unslash($_SERVER['QUERY_STRING']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
75
76 parse_str ( $raw_qs, $qs_args );
77
78 $clean_args = [ ];
79 foreach ( $qs_args as $key => $value ) {
80 $clean_key = sanitize_key ( $key );
81 $clean_value = is_array ( $value ) ? array_map ( 'sanitize_text_field', $value ) : sanitize_text_field ( $value );
82 $clean_args [$clean_key] = $clean_value;
83 }
84
85 $clean_qs = http_build_query ( $clean_args, '', '&', PHP_QUERY_RFC3986 );
86
87 if ($clean_qs !== '') {
88 $pageLink = strtok ( $pageLink, '?' ) . '?' . $clean_qs;
89 }
90 }
91
92 // Strip entire query string if any server-injected parameter is detected (e.g. q, fbclid)
93 // Acts as a conditional ignore_querystring=1 triggered by specific parameter names
94 if (!empty($settings['serverside_translations_strip_querystring_params']) && strpos($pageLink, '?') !== false) {
95 $stripParams = array_map('trim', explode(',', strtolower($settings['serverside_translations_strip_querystring_params'])));
96 $pageLinkQs = parse_url($pageLink, PHP_URL_QUERY);
97 if ($pageLinkQs) {
98 parse_str($pageLinkQs, $qsArgs);
99 foreach ($stripParams as $param) {
100 if (isset($qsArgs[$param])) {
101 $pageLink = strtok($pageLink, '?');
102 break;
103 }
104 }
105 }
106 }
107
108 if ( $settings ['serverside_translations_urlencode_space'] ) {
109 $pageLink = str_ireplace ( ' ', '%20', $pageLink );
110 }
111
112 $table = $wpdb->prefix . 'gptranslate';
113
114 if( $settings ['rewrite_language_url'] && $settings ['rewrite_language_alias'] ) {
115 $row = $wpdb->get_row ( $wpdb->prepare ( "SELECT pagelink, translations, alt_translations FROM {$wpdb->prefix}gptranslate" . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
116 "\n WHERE ( pagelink = %s OR pagelink = %s OR translated_alias = %s OR translated_alias = %s) AND languageoriginal = %s AND languagetranslated = %s AND published = 1",
117 rtrim($pageLink, '/'), rtrim($pageLink, '/') . '/', rtrim($pageLink, '/'), rtrim($pageLink, '/') . '/', $originalLang, $translatedLang ), ARRAY_A );
118 } else {
119 $row = $wpdb->get_row ( $wpdb->prepare ( "SELECT pagelink, translations, alt_translations FROM {$wpdb->prefix}gptranslate" . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
120 "\n WHERE ( pagelink = %s OR pagelink = %s ) AND languageoriginal = %s AND languagetranslated = %s AND published = 1",
121 rtrim($pageLink, '/'), rtrim($pageLink, '/') . '/', $originalLang, $translatedLang ), ARRAY_A );
122 }
123
124 if (! $row) {
125 return $html;
126 }
127
128 // Load translated aliases to replace on page links
129 $translatedAliasesMap = []; // Map absolute URLs
130 $translatedAliasesRelativeMap = []; // Map relative URL (pathname)
131
132 // Store settings for keep context flatten tags
133 $flattenInnerFormattingTags = isset($settings['flatten_inner_formatting_tags']) ? $settings['flatten_inner_formatting_tags'] : 0;
134 $flattenInnerFormattingTagsToRemove = isset($settings['flatten_inner_formatting_tags_to_remove']) ? explode(',', $settings['flatten_inner_formatting_tags_to_remove']) : explode(',', 'strong,em,u,b,i');
135 $wrapExcludedWords = isset($settings['wrap_excluded_words']) ? $settings['wrap_excluded_words'] : 0;
136
137 if (!empty($settings['rewrite_page_links']) && !empty($settings['rewrite_language_url']) && !empty($settings['rewrite_language_alias'])) {
138 try {
139 $aliasesResult = $wpdb->get_results( $wpdb->prepare(
140 "SELECT pagelink, translated_alias" .
141 "\n FROM {$table}" .
142 "\n WHERE languagetranslated = %s" .
143 "\n AND published = 1",
144 $translatedLang
145 ), ARRAY_A );
146
147 if ($aliasesResult) {
148 $parsedRoot = trailingslashit(get_site_url());
149
150 foreach ($aliasesResult as $rowAliasResult) {
151 // NORMALIZE, WORDPRESS STYLE (KEEP TRAILING SLASH)
152 $pagelink = gpt_trailingslashit_url($rowAliasResult['pagelink']);
153 $translatedAlias = $rowAliasResult['translated_alias'] ?? '';
154
155 if (empty($translatedAlias)) continue;
156
157 $translatedAlias = gpt_trailingslashit_url($translatedAlias);
158
159 // First pass: use decoded pagelink and alias as key (DB stores decoded UTF-8)
160 $translatedAliasesMap[$pagelink] = $translatedAlias;
161
162 // Second pass: use decoded relative path as key
163 $relativePath = str_replace($parsedRoot, '/', $pagelink);
164 $relativePath = parse_url($relativePath, PHP_URL_PATH);
165 if ($relativePath) {
166 $translatedAliasesRelativeMap[gpt_trailingslashit_url($relativePath)] = $translatedAlias;
167 }
168 }
169 }
170 } catch (Exception $e) {
171 // Silently fail
172 }
173 }
174
175 // Process replacement method
176 if ($settings ['serverside_translations_method'] == 'regex') {
177 $translations = json_decode ( $row ['translations'], true ) ?? [ ];
178 $altTranslations = json_decode ( $row ['alt_translations'], true ) ?? [ ];
179 uksort ( $translations, fn ($a, $b) => strlen ( $b ) - strlen ( $a ) );
180 uksort ( $altTranslations, fn ($a, $b) => strlen ( $b ) - strlen ( $a ) );
181
182 // Flatten formatting tags in the body to improve matching if enabled
183 if ($flattenInnerFormattingTags) {
184 $regexTagsToRemove = implode('|', array_map('preg_quote', $flattenInnerFormattingTagsToRemove));
185 function gp_flatten_formatting_tags_regex($html, $regexTagsToRemove) {
186 $pattern = '~<(' . $regexTagsToRemove . ')(\s[^>]*)?>(.*?)</\1>~is';
187
188 // Ripeti finché non ci sono più match (gestisce annidamenti semplici)
189 $prev = null;
190 while ($prev !== $html) {
191 $prev = $html;
192 $html = preg_replace_callback($pattern, function ($m) {
193 // Preserva elementi che contengono form elements o media (select, input, textarea, button, img, ecc.)
194 if (preg_match('/<(select|input|textarea|button|option|optgroup|fieldset|datalist|output|label|form|img|video|audio|canvas|svg|iframe)[\s>\/]/i', $m[3])) {
195 return $m[0]; // restituisci il tag originale intatto
196 }
197 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
198 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', strip_tags($m[3]))) === '') {
199 return $m[0];
200 }
201 // tieni SOLO il testo interno (se dentro c’è altro HTML lo butta via)
202 $inner = strip_tags($m[3]);
203 return $inner;
204 }, $html);
205 }
206
207 return $html;
208 }
209 $html = gp_flatten_formatting_tags_regex($html, $regexTagsToRemove);
210 }
211
212 $caseInsensitive = ! empty ( $settings ['serverside_translations_caseinsensitive'] );
213 $matchQuotes = ! empty ( $settings ['serverside_translations_matchquotes'] );
214 $excludedPatterns = [ ];
215
216 $excludedCss = preg_replace ( '/,+/', ',', str_ireplace ( [
217 "\r",
218 "\n",
219 '"'
220 ], [
221 ',',
222 ',',
223 ''
224 ], $settings ['css_selector_serverside_leafnodes_excluded'] ?? '') );
225 $excludedCss = array_filter ( array_map ( 'trim', explode ( ',', $excludedCss ) ) );
226
227 foreach ( $excludedCss as $selector ) {
228 if (preg_match ( '/^([a-z0-9]+)\.(.+)$/i', $selector, $m )) {
229 $excludedPatterns [] = '/<' . preg_quote ( $m [1], '/' ) . '(?=[^>]*\sclass\s*=\s*["\'][^"\']*\b' . preg_quote ( $m [2], '/' ) . '\b)[^>]*>/i';
230 } elseif (preg_match ( '/^\.(.+)$/', $selector, $m )) {
231 $excludedPatterns [] = '/<([a-z0-9]+)(?=[^>]*\sclass\s*=\s*["\'][^"\']*\b' . preg_quote ( $m [1], '/' ) . '\b)[^>]*>/i';
232 }
233 }
234
235 $segments = preg_split ( '/(<[^>]+>)/i', $html, - 1, PREG_SPLIT_DELIM_CAPTURE );
236
237 $skipStack = [ ];
238 foreach ( $segments as $index => $segment ) {
239 if (preg_match ( '/^<\s*(script|style)(\s|>)/i', $segment, $matches )) {
240 $skipStack [] = strtolower ( $matches [1] );
241 } elseif (preg_match ( '/<\/\s*(script|style)[^>]*>/i', $segment, $matches )) { // <--- FIX QUI
242 $tag = strtolower ( $matches [1] );
243 if (! empty ( $skipStack ) && end ( $skipStack ) === $tag) {
244 array_pop ( $skipStack );
245 }
246 } elseif (preg_match ( '/^<\s*([a-zA-Z0-9]+)/', $segment, $tagMatch )) {
247 $tagName = strtolower ( $tagMatch [1] );
248 foreach ( $excludedPatterns as $pattern ) {
249 if (preg_match ( $pattern, $segment )) {
250 $skipStack [] = $tagName;
251 break;
252 }
253 }
254 } elseif (preg_match ( '/^<\/\s*([a-zA-Z0-9]+)/', $segment, $tagMatch )) {
255 $tagName = strtolower ( $tagMatch [1] );
256 if (! empty ( $skipStack ) && end ( $skipStack ) === $tagName) {
257 array_pop ( $skipStack );
258 }
259 } elseif (! preg_match ( '/^<[^>]+>$/', $segment )) {
260 if (empty ( $skipStack )) {
261 if ($matchQuotes) {
262 $segment = str_ireplace ( '"', "'", $segment );
263 }
264 // Normalize once without trimming to preserve spaces between nodes
265 $segment = normalizeTextNoTrim($segment);
266 foreach ( $translations as $originalText => $translatedText ) {
267 $originalText = normalizeText($originalText);
268
269 $prevSegment = $segment;
270 if ($wrapExcludedWords) {
271 // Use word boundaries to avoid sub-replacing inside already translated words
272 $pattern = '/(?<!\w)' . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
273 $segment = preg_replace($pattern, $translatedText, $segment);
274 } else {
275 // Use word boundaries to avoid sub-replacing inside already translated words
276 $pattern = '/(?<!\w)' . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
277 $segment = preg_replace($pattern, $translatedText, $segment);
278 if ($segment !== $prevSegment) {
279 break;
280 }
281 }
282 }
283 }
284 }
285
286 $segments [$index] = $segment;
287 }
288
289 $html = implode ( '', $segments );
290
291 // Replace alt and title in images
292 if (! empty ( $settings ['translate_altimages'] )) {
293 $html = preg_replace_callback ( '/<img[^>]*\b(alt|title)\s*=\s*([\"\'])(.*?)\2[^>]*>/i', function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
294 $attr = $matches [1];
295 $quote = $matches [2];
296 $value = $matches [3];
297 if ($matchQuotes)
298 $value = str_ireplace ( '"', "'", $value );
299 foreach ( $altTranslations as $original => $translated ) {
300 $original = normalizeText($original);
301 $value = normalizeText($value);
302
303 $value = $caseInsensitive ? str_ireplace ( trim ( $original ), $translated, $value ) : str_replace ( trim ( $original ), $translated, $value );
304 }
305 return preg_replace ( '/\b' . $attr . '\s*=\s*["\'].*?["\']/', "$attr=$quote$value$quote", $matches [0] );
306 }, $html );
307
308 // Check if also images 'src' are enabled to be translated
309 if (! empty ( $settings ['translate_srcimages'] )) {
310 $html = preg_replace_callback('/<img\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1[^>]*>/i', function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
311 $originalTag = $matches[0];
312 $quote = $matches[1];
313 $srcValue = ltrim($matches[2], '/');
314
315 if ($matchQuotes) {
316 $srcValue = str_ireplace('"', "'", $srcValue);
317 }
318
319 $translatedSrcValue = null;
320 foreach ($altTranslations as $originalText => $translatedText) {
321 $originalText = ltrim($originalText, '/');
322 if ($caseInsensitive) {
323 if (strcasecmp(trim($originalText), $srcValue) === 0) {
324 $translatedSrcValue = $translatedText;
325 break;
326 }
327 } else {
328 if (trim($originalText) === $srcValue) {
329 $translatedSrcValue = $translatedText;
330 break;
331 }
332 }
333 }
334
335 // If a translation was found, replace src and srcset
336 if ($translatedSrcValue !== null) {
337 // Replace the src attribute
338 $modifiedTag = preg_replace('/\bsrc\s*=\s*["\'].*?["\']/i', 'src=' . $quote . $translatedSrcValue . $quote, $originalTag);
339
340 // Check if srcset exists and replace it with the translated src value
341 if (preg_match('/\bsrcset\s*=\s*(["\'])(.*?)\1/i', $modifiedTag, $srcsetMatch)) {
342 $srcsetQuote = $srcsetMatch[1];
343 $modifiedTag = preg_replace('/\bsrcset\s*=\s*["\'].*?["\']/i', 'srcset=' . $srcsetQuote . $translatedSrcValue . $srcsetQuote, $modifiedTag);
344 }
345
346 return $modifiedTag;
347 }
348
349 return $originalTag;
350 }, $html);
351 }
352 }
353
354 // Translate <meta name="description" content="...">
355 $html = preg_replace_callback(
356 '~<meta\s+(?:name|property)=["\'](?:description|og:description|twitter:description)["\']\s+content=["\'](.*?)["\'][^>]*>~i',
357 function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
358 $originalTag = $matches[0];
359 $contentValue = $matches[1];
360
361 // DECODIFICA HTML entities PRIMA del confronto
362 $contentValue = html_entity_decode($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
363
364 if ($matchQuotes) {
365 $contentValue = str_ireplace('"', "'", $contentValue);
366 }
367
368 foreach ($altTranslations as $originalText => $translatedText) {
369 $prev = $contentValue;
370 if ($caseInsensitive) {
371 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
372 } else {
373 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
374 }
375 if ($contentValue !== $prev) break;
376 }
377
378 // RI-ENCODIFICA per l'HTML output (sicurezza)
379 $contentValue = htmlspecialchars($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
380
381 return preg_replace(
382 '/content=["\'].*?["\']/i',
383 'content="' . $contentValue . '"',
384 $originalTag
385 );
386 },
387 $html
388 );
389
390 // Translate <meta property="og:title"> with the same value as <title> tag
391 // Step 1: Extract the translated title content
392 if (preg_match('~<title[^>]*>(.*?)</title>~is', $html, $titleMatch)) {
393 $translatedTitle = trim(strip_tags($titleMatch[1]));
394
395 // Step 2: Replace og:title content directly
396 $html = preg_replace(
397 '~(<meta\s+property=["\']og:title["\']\s+content=["\']).*?(["\'][^>]*>)~i',
398 '$1' . htmlspecialchars($translatedTitle, ENT_QUOTES, 'UTF-8') . '$2',
399 $html
400 );
401
402 // Step 3: Replace twitter:title content with the same translated title
403 $html = preg_replace(
404 '~(<meta\s+name=["\']twitter:title["\']\s+content=["\']).*?(["\'][^>]*>)~i',
405 '$1' . htmlspecialchars($translatedTitle, ENT_QUOTES, 'UTF-8') . '$2',
406 $html
407 );
408 }
409
410 // Add skip marker
411 $html = preg_replace ( '/<body/i', '<body data-gptranslateskip="1" data-gptranslateoriginalalias="' . $row['pagelink'] . '"', $html, 1 );
412
413 // Replace page links in <a href="..."> tags based on translated aliases
414 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
415 $html = preg_replace_callback(
416 '/<a\s+([^>]*\s)?href\s*=\s*(["\'])(.*?)\2([^>]*)>/i',
417 function($matches) use ($translatedAliasesMap, $translatedAliasesRelativeMap, $settings) {
418 $fullTag = $matches[0];
419 $quote = $matches[2];
420 $href = $matches[3];
421
422 // Decode href attribute to match database entries
423 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
424
425 // Extract hash/fragment if present
426 $hashFragment = '';
427 if (strpos($decodedHref, '#') !== false) {
428 $parts = explode('#', $decodedHref, 2);
429 $decodedHref = $parts[0]; // URL without hash
430 $hashFragment = '#' . $parts[1]; // Save the hash
431 }
432
433 $queryString = '';
434 if ($settings ['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
435 $parts = explode('?', $decodedHref, 2);
436 $decodedHref = $parts[0];
437 $queryString = '?' . $parts[1];
438 }
439 $originalPathBeforeNormalization = $decodedHref;
440
441 $decodedHref = gpt_trailingslashit_url($decodedHref);
442
443 $translatedAlias = null;
444
445 // First pass, check in absolute URLs
446 if (isset($translatedAliasesMap[$decodedHref])) {
447 $translatedAlias = $translatedAliasesMap[$decodedHref];
448 }
449 // Second pass, check in relative URLs (pathname)
450 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
451 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
452 }
453
454 // If found a translated alias, replace href and add data-originalhref if not present
455 if ($translatedAlias) {
456 $finalHref = $translatedAlias . $queryString . $hashFragment;
457
458 $originalHrefFull = $originalPathBeforeNormalization . $queryString . $hashFragment;
459
460 // Check if data-originalhref already exists in the tag
461 if (strpos($fullTag, 'data-originalhref') === false) {
462 $dataAttr = ' data-originalhref=' . $quote . htmlspecialchars($originalHrefFull, ENT_QUOTES, 'UTF-8') . $quote;
463 $newTag = str_ireplace('<a ', '<a' . $dataAttr . ' ', $fullTag);
464 $newTag = str_ireplace('href=' . $quote . $href . $quote, 'href=' . $quote . htmlspecialchars($finalHref, ENT_QUOTES, 'UTF-8') . $quote, $newTag);
465 return $newTag;
466 } else {
467 return str_ireplace('href=' . $quote . $href . $quote, 'href=' . $quote . htmlspecialchars($finalHref, ENT_QUOTES, 'UTF-8') . $quote, $fullTag);
468 }
469 }
470
471 return $fullTag;
472 },
473 $html
474 );
475
476 // Replace form action URLs: add language prefix and apply translated aliases
477 if (!empty($settings['rewrite_form_actions'])) {
478 $siteUrl = trailingslashit(get_site_url());
479 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
480 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
481 $html = preg_replace_callback(
482 '/<form\s+([^>]*\s)?action\s*=\s*(["\'])(.*?)\2([^>]*)>/i',
483 function($matches) use ($translatedAliasesMap, $translatedAliasesRelativeMap, $settings, $translatedLang, $siteUrl, $siteHost, $knownLangs) {
484 $fullTag = $matches[0];
485 $quote = $matches[2];
486 $action = $matches[3];
487
488 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
489
490 // Skip anchors, mailto, tel, javascript, .php endpoints
491 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) return $fullTag;
492 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) return $fullTag;
493
494 // Skip external URLs
495 if (preg_match('/^https?:\/\//i', $decodedAction)) {
496 $actionHost = parse_url($decodedAction, PHP_URL_HOST);
497 if ($actionHost !== $siteHost) return $fullTag;
498 }
499
500 // Extract query string
501 $queryString = '';
502 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
503 $parts = explode('?', $decodedAction, 2);
504 $decodedAction = $parts[0];
505 $queryString = '?' . $parts[1];
506 }
507 $originalActionFull = $decodedAction . $queryString;
508
509 // Extract relative path
510 $actionPath = $decodedAction;
511 $isAbsolute = false;
512 if (preg_match('/^https?:\/\//i', $actionPath)) {
513 $isAbsolute = true;
514 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
515 }
516
517 // Split path and determine language index
518 $pathParts = explode('/', $actionPath);
519 $langIndex = 1;
520 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') {
521 $langIndex = 2;
522 }
523 if (!empty($settings['subfolder_installation'])) {
524 $langIndex = 2;
525 }
526
527 // Check if already has a language prefix, replace or insert
528 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
529 $pathParts[$langIndex] = $translatedLang;
530 } else {
531 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
532 }
533
534 // Rebuild path
535 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
536 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
537
538 // Rebuild full URL if original was absolute
539 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
540 $computedAction = gpt_trailingslashit_url($computedAction);
541
542 // Try alias match
543 $translatedAlias = null;
544 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
545 if (isset($translatedAliasesMap[$computedAction])) {
546 $translatedAlias = $translatedAliasesMap[$computedAction];
547 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
548 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
549 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
550 // Fallback: form action is relative and includes subfolder path
551 // Map keys strip the subfolder, so retry after stripping it
552 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
553 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
554 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
555 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
556 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
557 }
558 }
559 }
560 }
561
562 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
563
564 if (strpos($fullTag, 'data-originalaction') === false) {
565 $dataAttr = ' data-originalaction=' . $quote . htmlspecialchars($originalActionFull, ENT_QUOTES, 'UTF-8') . $quote;
566 $newTag = str_ireplace('<form ', '<form' . $dataAttr . ' ', $fullTag);
567 $newTag = str_ireplace('action=' . $quote . $action . $quote, 'action=' . $quote . htmlspecialchars($finalAction, ENT_QUOTES, 'UTF-8') . $quote, $newTag);
568 return $newTag;
569 } else {
570 return str_ireplace('action=' . $quote . $action . $quote, 'action=' . $quote . htmlspecialchars($finalAction, ENT_QUOTES, 'UTF-8') . $quote, $fullTag);
571 }
572 },
573 $html
574 );
575 } // end rewrite_form_actions
576 }
577 } elseif ($settings ['serverside_translations_method'] == 'domdocument') {
578 // Solution 2: classic DOMDocument approach, effective but could cause closing tags and encoding issues
579 try {
580 $translationsArray = json_decode ( $row['translations'], true ) ?? [ ];
581 $altTranslationsArray = json_decode ( $row['alt_translations'] ?? '', true ) ?: [ ];
582
583 // Sort the translation keys in descending order by length.
584 uksort ( $translationsArray, function ( $a, $b ) {
585 return strlen ( $b ) - strlen ( $a );
586 } );
587
588 // Sort the alt translation keys in descending order by length.
589 uksort ( $altTranslationsArray, function ( $a, $b ) {
590 return strlen ( $b ) - strlen ( $a );
591 } );
592
593 // Flatten formatting tags in the body to improve matching if enabled
594 if($flattenInnerFormattingTags) {
595 function gp_flatten_inner_formatting_tags($html, $tagsToRemove) {
596 $dom = new DOMDocument();
597 libxml_use_internal_errors(true);
598
599 // Usa lo stesso metodo del codice principale con l'hack XML encoding
600 $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
601
602 libxml_clear_errors();
603
604 $xpath = new DOMXPath($dom);
605
606 // Seleziona tutti i tag da rimuovere, ovunque
607 $query = '//' . implode(' | //', array_map(function($t) { return strtolower($t); }, $tagsToRemove));
608
609 $nodes = $xpath->query($query);
610
611 // Importante: sostituire dal "basso verso l'alto" per evitare problemi mentre modifichi il DOM
612 // Convertiamo NodeList in array e invertiamo
613 $toReplace = [];
614 foreach ($nodes as $n)
615 $toReplace[] = $n;
616 $toReplace = array_reverse($toReplace);
617
618 foreach ($toReplace as $node) {
619 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
620 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', $node->textContent)) === '') {
621 continue;
622 }
623 // Preserva elementi che contengono form elements (select, input, textarea, button, ecc.)
624 $interactiveTags = ['select', 'input', 'textarea', 'button', 'option', 'optgroup', 'fieldset', 'datalist', 'output', 'label', 'form', 'img', 'video', 'audio', 'canvas', 'svg', 'iframe'];
625 $hasInteractive = false;
626 foreach ($interactiveTags as $iTag) {
627 if ($node->getElementsByTagName($iTag)->length > 0) {
628 $hasInteractive = true;
629 break;
630 }
631 }
632 if ($hasInteractive) {
633 continue;
634 }
635
636 $text = $node->textContent; // prende il testo "visibile" (quello che vuoi)
637 $textNode = $dom->createTextNode($text);
638
639 if ($node->parentNode) {
640 $node->parentNode->replaceChild($textNode, $node);
641 }
642 }
643
644 // Salva l'HTML modificato
645 $out = $dom->saveHTML();
646
647 return $out;
648 }
649 $html = gp_flatten_inner_formatting_tags($html, $flattenInnerFormattingTagsToRemove);
650 }
651
652 // Initialize DOMDocument without converting the body content via mb_convert_encoding.
653 $doc = new DOMDocument ();
654 libxml_use_internal_errors ( true );
655 // Use an XML encoding hack to inform DOMDocument about UTF-8 while preserving original characters.
656 $doc->loadHTML ( '<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
657 libxml_clear_errors ();
658
659 // Use XPath to locate text nodes, skipping those in <script> or <style> tags.
660 $xpath = new DOMXPath ( $doc );
661 $cssSelectorLeafnodesExcluded = str_ireplace ( '"', '', trim ( trim ( preg_replace ( '/,+/', ',', str_ireplace ( [ "\r", "\n" ], ",", $settings['css_selector_serverside_leafnodes_excluded'] ?? '' ) ) ), ',' ) );
662 $excludedNodes = [ ];
663
664 if (! empty ( $cssSelectorLeafnodesExcluded )) {
665 $selectors = explode ( ',', $cssSelectorLeafnodesExcluded );
666 $xpathQueries = [ ];
667
668 foreach ( $selectors as $selector ) {
669 $selector = trim ( $selector );
670 if (empty ( $selector ))
671 continue;
672
673 // Convert CSS selector to XPath
674 $selector = preg_replace ( '/\s+/', ' ', $selector ); // Normalize spaces
675 $selectorParts = explode ( ' ', $selector );
676
677 $xpathQuery = '';
678 foreach ( $selectorParts as $part ) {
679 if (preg_match ( '/^([a-zA-Z0-9_-]+)?(\.[a-zA-Z0-9_-]+)?(#[a-zA-Z0-9_-]+)?$/', $part, $matches )) {
680 $tag = ! empty ( $matches[1] ) ? $matches[1] : '*'; // If no tag, use '*'
681 $class = ! empty ( $matches[2] ) ? substr ( $matches[2], 1 ) : ''; // Remove leading '.'
682 $id = ! empty ( $matches[3] ) ? substr ( $matches[3], 1 ) : ''; // Remove leading '#'
683
684 $conditions = [ ];
685 if ($class) {
686 $conditions[] = "contains(concat(' ', normalize-space(@class), ' '), ' $class ')";
687 }
688 if ($id) {
689 $conditions[] = "@id='$id'";
690 }
691
692 $xpathQuery .= "//{$tag}" . (! empty ( $conditions ) ? "[" . implode ( " and ", $conditions ) . "]" : "");
693 }
694 }
695
696 if (! empty ( $xpathQuery )) {
697 $xpathQueries[] = $xpathQuery;
698 }
699 }
700
701 // Execute all XPath queries and collect excluded nodes
702 foreach ( $xpathQueries as $query ) {
703 foreach ( $xpath->query ( $query ) as $excludedNode ) {
704 $excludedNodes[] = $excludedNode;
705 }
706 }
707 }
708
709 $textNodes = $xpath->query ( '//text()[not(ancestor::script) and not(ancestor::style) and normalize-space()]' );
710 $caseInsensitive = ! empty ( $settings['serverside_translations_caseinsensitive'] );
711 $matchQuotes = ! empty ( $settings['serverside_translations_matchquotes'] );
712
713 // Process each text node with translation replacements.
714 foreach ( $textNodes as $textNode ) {
715 if ($cssSelectorLeafnodesExcluded) {
716 $skipNode = false;
717 foreach ( $excludedNodes as $excludedNode ) {
718 if ($excludedNode->isSameNode ( $textNode->parentNode )) {
719 $skipNode = true;
720 break;
721 }
722 }
723 if ($skipNode)
724 continue;
725 }
726
727 $originalTextContent = $textNode->nodeValue;
728 $textContent = $textNode->nodeValue;
729 if ($matchQuotes) {
730 $textContent = str_ireplace ( '"', "'", $textContent );
731 }
732 // Normalize once without trimming to preserve spaces between nodes
733 $textContent = normalizeTextNoTrim($textContent);
734 foreach ( $translationsArray as $originalText => $translatedText ) {
735 $originalText = normalizeText($originalText);
736
737 $prevTextContent = $textContent; // Save the current state
738 if ($wrapExcludedWords) {
739 // Use word boundaries to avoid sub-replacing inside already translated words
740 $pattern = '/(?<!\w)' . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
741 $textContent = preg_replace($pattern, $translatedText, $textContent);
742 } else {
743 // Use word boundaries to avoid sub-replacing inside already translated words
744 $pattern = '/(?<!\w)' . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
745 $textContent = preg_replace($pattern, $translatedText, $textContent);
746 if ($textContent !== $prevTextContent) {
747 break;
748 }
749 }
750 }
751 $textNode->nodeValue = $textContent;
752 }
753
754 // Replace page links in <a href="..."> tags based on translated aliases
755 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
756 $linkNodes = $xpath->query('//a[@href]');
757 foreach ($linkNodes as $linkNode) {
758 $href = $linkNode->getAttribute('href');
759
760 // Decode href attribute (WordPress-style, KEEP trailing slash)
761 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
762
763 // Extract hash/fragment if present
764 $hashFragment = '';
765 if (strpos($decodedHref, '#') !== false) {
766 $parts = explode('#', $decodedHref, 2);
767 $decodedHref = $parts[0]; // URL without hash
768 $hashFragment = '#' . $parts[1]; // Save the hash
769 }
770
771 $queryString = '';
772 if ($settings['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
773 $parts = explode('?', $decodedHref, 2);
774 $decodedHref = $parts[0];
775 $queryString = '?' . $parts[1];
776 }
777 $originalPathBeforeNormalization = $decodedHref;
778
779 $decodedHref = gpt_trailingslashit_url($decodedHref);
780
781 $translatedAlias = null;
782
783 // Cerca prima negli URL assoluti
784 if (isset($translatedAliasesMap[$decodedHref])) {
785 $translatedAlias = $translatedAliasesMap[$decodedHref];
786 }
787 // Poi cerca negli URL relativi
788 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
789 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
790 }
791
792 // Se trovato un alias tradotto, sostituisci
793 if ($translatedAlias) {
794 // Aggiungi data-originalhref se non presente
795 if (!$linkNode->hasAttribute('data-originalhref')) {
796 $linkNode->setAttribute('data-originalhref', $originalPathBeforeNormalization . $queryString . $hashFragment);
797 }
798 $linkNode->setAttribute('href', $translatedAlias . $queryString . $hashFragment);
799 }
800 }
801 }
802
803 // Replace form action URLs: add language prefix and apply translated aliases
804 if (!empty($settings['rewrite_form_actions'])) {
805 $siteUrl = trailingslashit(get_site_url());
806 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
807 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
808 $formNodes = $xpath->query('//form[@action]');
809 foreach ($formNodes as $formNode) {
810 $action = $formNode->getAttribute('action');
811 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
812
813 // Skip non-rewritable actions
814 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) continue;
815 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) continue;
816 if (preg_match('/^https?:\/\//i', $decodedAction) && parse_url($decodedAction, PHP_URL_HOST) !== $siteHost) continue;
817
818 $queryString = '';
819 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
820 $parts = explode('?', $decodedAction, 2);
821 $decodedAction = $parts[0];
822 $queryString = '?' . $parts[1];
823 }
824 $originalActionFull = $decodedAction . $queryString;
825
826 // Extract relative path
827 $actionPath = $decodedAction;
828 $isAbsolute = false;
829 if (preg_match('/^https?:\/\//i', $actionPath)) {
830 $isAbsolute = true;
831 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
832 }
833
834 // Add/replace language prefix
835 $pathParts = explode('/', $actionPath);
836 $langIndex = 1;
837 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') $langIndex = 2;
838 if (!empty($settings['subfolder_installation'])) $langIndex = 2;
839
840 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
841 $pathParts[$langIndex] = $translatedLang;
842 } else {
843 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
844 }
845
846 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
847 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
848 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
849 $computedAction = gpt_trailingslashit_url($computedAction);
850
851 // Try alias match
852 $translatedAlias = null;
853 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
854 if (isset($translatedAliasesMap[$computedAction])) {
855 $translatedAlias = $translatedAliasesMap[$computedAction];
856 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
857 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
858 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
859 // Fallback: form action is relative and includes subfolder path
860 // Map keys strip the subfolder, so retry after stripping it
861 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
862 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
863 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
864 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
865 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
866 }
867 }
868 }
869 }
870
871 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
872
873 if (!$formNode->hasAttribute('data-originalaction')) {
874 $formNode->setAttribute('data-originalaction', $originalActionFull);
875 }
876 $formNode->setAttribute('action', $finalAction);
877 }
878 }
879
880 // Check if also images 'alt' are enabled to be translated
881 if (! empty ( $settings['translate_altimages'] )) {
882 $imgNodesAlt = $xpath->query ( '//img[@alt]' );
883 foreach ( $imgNodesAlt as $imgNode ) {
884 $altText = $imgNode->getAttribute ( 'alt' );
885 if ($matchQuotes) {
886 $altText = str_ireplace ( '"', "'", $altText );
887 }
888 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
889 $originalText = normalizeText($originalText);
890 $altText = normalizeText($altText);
891
892 $prevAltText = $altText;
893 if ($caseInsensitive) {
894 $altText = str_ireplace ( trim ( $originalText ), $translatedText, $altText );
895 } else {
896 $altText = str_replace ( trim ( $originalText ), $translatedText, $altText );
897 }
898 if ($altText !== $prevAltText) {
899 break;
900 }
901 }
902 $imgNode->setAttribute ( 'alt', $altText );
903 }
904
905 $imgNodesTitle = $xpath->query ( '//img[@title]' );
906 foreach ( $imgNodesTitle as $imgNode ) {
907 $titleText = $imgNode->getAttribute ( 'title' );
908 if ($matchQuotes) {
909 $titleText = str_ireplace ( '"', "'", $titleText );
910 }
911 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
912 $originalText = normalizeText($originalText);
913 $titleText = normalizeText($titleText);
914
915 $prevTitleText = $titleText;
916 if ($caseInsensitive) {
917 $titleText = str_ireplace ( trim ( $originalText ), $translatedText, $titleText );
918 } else {
919 $titleText = str_replace ( trim ( $originalText ), $translatedText, $titleText );
920 }
921 if ($titleText !== $prevTitleText) {
922 break;
923 }
924 }
925 $imgNode->setAttribute ( 'title', $titleText );
926 }
927
928 // Check if also images 'src' are enabled to be translated
929 if (! empty ( $settings ['translate_srcimages'] )) {
930 $imgNodesSrc = $xpath->query ( '//img[@src]' );
931 foreach ( $imgNodesSrc as $imgNode ) {
932 $srcValue = ltrim ( $imgNode->getAttribute ( 'src' ), '/' );
933
934 if ($matchQuotes) {
935 $srcValue = str_ireplace ( '"', "'", $srcValue );
936 }
937
938 $translatedSrcValue = null;
939 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
940 $originalText = normalizeText($originalText);
941 $srcValue = normalizeText($srcValue);
942
943 $originalText = ltrim ( $originalText, '/' );
944 if ($caseInsensitive) {
945 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
946 $translatedSrcValue = $translatedText;
947 break;
948 }
949 } else {
950 if (trim ( $originalText ) === $srcValue) {
951 $translatedSrcValue = $translatedText;
952 break;
953 }
954 }
955 }
956
957 // If a translation was found, replace src and srcset
958 if ($translatedSrcValue !== null) {
959 // Replace the src attribute
960 $imgNode->setAttribute ( 'src', $translatedSrcValue );
961
962 // Check if srcset exists and replace it with the translated src value
963 if ($imgNode->hasAttribute ( 'srcset' )) {
964 $imgNode->setAttribute ( 'srcset', $translatedSrcValue );
965 }
966 }
967 }
968 }
969 }
970
971 // Translate meta descriptions (standard, Open Graph, Twitter)
972 $metaDescriptions = $xpath->query(
973 '//meta[
974 (@name="description")
975 or (@name="twitter:description")
976 or (@property="og:description")
977 ]'
978 );
979 foreach ($metaDescriptions as $meta) {
980 $contentValue = $meta->getAttribute('content');
981
982 if ($matchQuotes) {
983 $contentValue = str_ireplace('"', "'", $contentValue);
984 }
985
986 foreach ($altTranslationsArray as $originalText => $translatedText) {
987 $prev = $contentValue;
988 if ($caseInsensitive) {
989 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
990 } else {
991 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
992 }
993 if ($contentValue !== $prev) break;
994 }
995
996 $meta->setAttribute('content', $contentValue);
997 }
998
999 $ogTitleNodes = $xpath->query('//meta[@property="og:title"]');
1000 if ($ogTitleNodes->length > 0) {
1001 // Find the <title> tag to get the translated title
1002 $titleNodes = $xpath->query('//title');
1003
1004 if ($titleNodes->length > 0) {
1005 $titleNode = $titleNodes->item(0);
1006 $translatedTitle = $titleNode->textContent;
1007
1008 // Set og:title with the same value as <title>
1009 foreach ($ogTitleNodes as $ogTitleNode) {
1010 $ogTitleNode->setAttribute('content', trim($translatedTitle));
1011 }
1012
1013 // Set twitter:title with the same value as <title>
1014 $twitterTitleNodes = $xpath->query('//meta[@name="twitter:title"]');
1015 foreach ($twitterTitleNodes as $twitterTitleNode) {
1016 $twitterTitleNode->setAttribute('content', trim($translatedTitle));
1017 }
1018 }
1019 }
1020
1021 // Optionally, add an attribute to the <body> tag to signal that translations were applied.
1022 $bodyTag = $doc->getElementsByTagName ( 'body' )->item ( 0 );
1023 if ($bodyTag) {
1024 $bodyTag->setAttribute ( 'data-gptranslateskip', '1' );
1025 $bodyTag->setAttribute ( 'data-gptranslateoriginalalias', htmlspecialchars($row['pagelink'], ENT_QUOTES, 'UTF-8') );
1026 }
1027
1028 // Save the modified HTML.
1029 $html = $doc->saveHTML ();
1030
1031 // Decode HTML entities back into UTF-8 characters.
1032 $html = html_entity_decode ( $html, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1033
1034 } catch ( Exception $e ) {
1035 // Handle exception if needed
1036 }
1037 } elseif ($settings['serverside_translations_method'] == 'simplehtmldom') {
1038 require_once plugin_dir_path(__FILE__) . 'simplehtmldom.php';
1039
1040 // Helper: checks if $child is inside (or is) $parent.
1041 function nodeIsInsideExcluded($child, $excludedNodes) {
1042 while ($child !== null) {
1043 foreach ($excludedNodes as $ex) {
1044 if ($child === $ex) {
1045 return true;
1046 }
1047 }
1048 $child = $child->parent;
1049 }
1050 return false;
1051 }
1052
1053 // Helper: checks if $node is inside (or is) any of the specified tags.
1054 function nodeIsInsideTags($node, $tagNames) {
1055 $tagNames = array_map('strtolower', $tagNames);
1056 while ($node !== null) {
1057 $tag = strtolower($node->tag ?? '');
1058 if (in_array($tag, $tagNames, true)) {
1059 return true;
1060 }
1061 $node = $node->parent;
1062 }
1063 return false;
1064 }
1065
1066 // Recursive function to process all text nodes.
1067 function processTextNodes($node, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes) {
1068 if ($node->tag === 'text') {
1069 if (! nodeIsInsideExcluded($node, $excludedNodes) && ! nodeIsInsideTags($node, ['script', 'style', 'noscript']) && trim($node->innertext) && $node->innertext != "\t") {
1070 $text = $node->innertext;
1071 if ($matchQuotes) {
1072 $text = str_ireplace('"', "'", $text);
1073 }
1074
1075 // Normalize once to avoid stripping spaces added by replacements
1076 $text = normalizeTextNoTrim($text);
1077
1078 // Apply translations without overriding longer translations
1079 $processedParts = [];
1080 foreach ($translationsArray as $originalText => $translatedText) {
1081 $originalText = normalizeText($originalText);
1082
1083 if ($caseInsensitive) {
1084 $text = preg_replace_callback(
1085 '/(?<!\w)' . preg_quote(trim($originalText), '/') . '(?!\w)/ui',
1086 function ($matches) use ($translatedText, &$processedParts) {
1087 if (in_array($matches[0], $processedParts, true)) {
1088 return $matches[0];
1089 }
1090 $processedParts[] = $translatedText;
1091 return $translatedText;
1092 },
1093 $text
1094 );
1095 } else {
1096 $text = preg_replace_callback(
1097 '/(?<!\w)' . preg_quote(trim($originalText), '/') . '(?!\w)/',
1098 function ($matches) use ($translatedText, &$processedParts) {
1099 if (in_array($matches[0], $processedParts, true)) {
1100 return $matches[0];
1101 }
1102 $processedParts[] = $translatedText;
1103 return $translatedText;
1104 },
1105 $text
1106 );
1107 }
1108 }
1109 $node->innertext = $text;
1110 }
1111 } else {
1112 $tagLower = strtolower($node->tag ?? '');
1113 if (in_array($tagLower, ['script', 'style'])) {
1114 return;
1115 }
1116 if (isset($node->nodes) && is_array($node->nodes)) {
1117 foreach ($node->nodes as $child) {
1118 processTextNodes($child, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes);
1119 }
1120 }
1121 }
1122 }
1123
1124 // Main processing code.
1125 try {
1126 $translationsArray = json_decode($row['translations'], true) ?? [];
1127 $altTranslationsArray = json_decode($row['alt_translations'] ?? '', true) ?: [];
1128
1129 // Sort translations by descending key length
1130 uksort($translationsArray, function ($a, $b) {
1131 return strlen($b) - strlen($a);
1132 });
1133
1134 // Sort alt translations by descending key length
1135 uksort($altTranslationsArray, function ($a, $b) {
1136 return strlen($b) - strlen($a);
1137 });
1138
1139 // Flatten formatting tags regex
1140 if($flattenInnerFormattingTags) {
1141 // Flatten formatting tags in the body to improve matching if enabled
1142 function gp_flatten_inner_formatting_tags_simplehtmldom($htmlString, $tagsToRemove ) {
1143 // Complete body parsing
1144 $doc = gptranslate_simplehtmldom_str_get_html ( $htmlString );
1145 if (! $doc)
1146 return $htmlString;
1147
1148 $selector = implode ( ',', $tagsToRemove );
1149
1150 $found = $doc->find($selector);
1151 if (!empty($found)) {
1152 for ($i = count($found) - 1; $i >= 0; $i--) {
1153 $n = $found[$i];
1154 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
1155 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', $n->plaintext)) === '') {
1156 continue;
1157 }
1158 // Preserva elementi che contengono form elements (select, input, textarea, button, ecc.)
1159 $interactiveTags = ['select', 'input', 'textarea', 'button', 'option', 'optgroup', 'fieldset', 'datalist', 'output', 'label', 'form', 'img', 'video', 'audio', 'canvas', 'svg', 'iframe'];
1160 $hasInteractive = false;
1161 foreach ($interactiveTags as $iTag) {
1162 if (!empty($n->find($iTag))) {
1163 $hasInteractive = true;
1164 break;
1165 }
1166 }
1167 if ($hasInteractive) {
1168 continue;
1169 }
1170 $n->outertext = $n->plaintext;
1171 }
1172 }
1173
1174 $out = $doc->save();
1175
1176 if (method_exists($doc, 'clear')) $doc->clear();
1177 unset($doc);
1178
1179 return $out;
1180 }
1181 $html = gp_flatten_inner_formatting_tags_simplehtmldom($html, $flattenInnerFormattingTagsToRemove);
1182 }
1183
1184 $htmlObj = gptranslate_simplehtmldom_str_get_html($html);
1185
1186 $cssSelectorLeafnodesExcluded = str_ireplace(
1187 '"',
1188 '',
1189 trim(trim(preg_replace('/,+/', ',', str_ireplace(["\r", "\n"], ",", $settings['css_selector_serverside_leafnodes_excluded'] ?? ''))), ',')
1190 );
1191
1192 $excludedNodes = [];
1193 if (! empty($cssSelectorLeafnodesExcluded)) {
1194 $selectors = explode(',', $cssSelectorLeafnodesExcluded);
1195 foreach ($selectors as $selector) {
1196 $selector = trim($selector);
1197 if (! empty($selector)) {
1198 $foundNodes = $htmlObj->find($selector);
1199 foreach ($foundNodes as $node) {
1200 $excludedNodes[] = $node;
1201 }
1202 }
1203 }
1204 }
1205
1206 $caseInsensitive = ! empty($settings['serverside_translations_caseinsensitive']);
1207 $matchQuotes = ! empty($settings['serverside_translations_matchquotes']);
1208
1209 processTextNodes($htmlObj, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes);
1210
1211 // Replace page links in <a href="..."> tags based on translated aliases
1212 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1213 foreach ($htmlObj->find('a[href]') as $linkNode) {
1214 $href = $linkNode->href;
1215
1216 // Decode href attribute (WordPress-style, KEEP trailing slash)
1217 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
1218
1219 // Extract hash/fragment if present
1220 $hashFragment = '';
1221 if (strpos($decodedHref, '#') !== false) {
1222 $parts = explode('#', $decodedHref, 2);
1223 $decodedHref = $parts[0]; // URL without hash
1224 $hashFragment = '#' . $parts[1]; // Save the hash
1225 }
1226
1227 // Extract query string if ignore_querystring is enabled
1228 $queryString = '';
1229 if ($settings['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
1230 $parts = explode('?', $decodedHref, 2);
1231 $decodedHref = $parts[0];
1232 $queryString = '?' . $parts[1];
1233 }
1234 $originalPathBeforeNormalization = $decodedHref;
1235
1236 $decodedHref = gpt_trailingslashit_url($decodedHref);
1237
1238 $translatedAlias = null;
1239
1240 // Cerca prima negli URL assoluti
1241 if (isset($translatedAliasesMap[$decodedHref])) {
1242 $translatedAlias = $translatedAliasesMap[$decodedHref];
1243 }
1244 // Poi cerca negli URL relativi
1245 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
1246 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
1247 }
1248
1249 // Se trovato un alias tradotto, sostituisci
1250 if ($translatedAlias) {
1251 // Aggiungi data-originalhref se non presente
1252 if (!isset($linkNode->{'data-originalhref'})) {
1253 $linkNode->{'data-originalhref'} = $originalPathBeforeNormalization . $queryString . $hashFragment;
1254 }
1255 $linkNode->href = $translatedAlias . $queryString . $hashFragment;
1256 }
1257 }
1258 }
1259
1260 // Replace form action URLs: add language prefix and apply translated aliases
1261 if (!empty($settings['rewrite_form_actions'])) {
1262 $siteUrl = trailingslashit(get_site_url());
1263 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
1264 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
1265 foreach ($htmlObj->find('form[action]') as $formNode) {
1266 $action = $formNode->action;
1267 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
1268
1269 // Skip non-rewritable actions
1270 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) continue;
1271 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) continue;
1272 if (preg_match('/^https?:\/\//i', $decodedAction) && parse_url($decodedAction, PHP_URL_HOST) !== $siteHost) continue;
1273
1274 $queryString = '';
1275 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
1276 $parts = explode('?', $decodedAction, 2);
1277 $decodedAction = $parts[0];
1278 $queryString = '?' . $parts[1];
1279 }
1280 $originalActionFull = $decodedAction . $queryString;
1281
1282 // Extract relative path
1283 $actionPath = $decodedAction;
1284 $isAbsolute = false;
1285 if (preg_match('/^https?:\/\//i', $actionPath)) {
1286 $isAbsolute = true;
1287 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
1288 }
1289
1290 // Add/replace language prefix
1291 $pathParts = explode('/', $actionPath);
1292 $langIndex = 1;
1293 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') $langIndex = 2;
1294 if (!empty($settings['subfolder_installation'])) $langIndex = 2;
1295
1296 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
1297 $pathParts[$langIndex] = $translatedLang;
1298 } else {
1299 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
1300 }
1301
1302 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
1303 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
1304 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
1305 $computedAction = gpt_trailingslashit_url($computedAction);
1306
1307 // Try alias match
1308 $translatedAlias = null;
1309 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1310 if (isset($translatedAliasesMap[$computedAction])) {
1311 $translatedAlias = $translatedAliasesMap[$computedAction];
1312 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
1313 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
1314 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
1315 // Fallback: form action is relative and includes subfolder path
1316 // Map keys strip the subfolder, so retry after stripping it
1317 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
1318 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
1319 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
1320 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
1321 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
1322 }
1323 }
1324 }
1325 }
1326
1327 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
1328
1329 if (!isset($formNode->{'data-originalaction'})) {
1330 $formNode->{'data-originalaction'} = $originalActionFull;
1331 }
1332 $formNode->action = $finalAction;
1333 }
1334 }
1335
1336 // Check if also images 'alt' are enabled to be translated
1337 if (! empty($settings['translate_altimages'])) {
1338 foreach ($htmlObj->find('img[alt]') as $imgNode) {
1339 $altText = $imgNode->alt;
1340 if ($matchQuotes) {
1341 $altText = str_ireplace('"', "'", $altText);
1342 }
1343 foreach ($altTranslationsArray as $originalText => $translatedText) {
1344 $originalText = normalizeText($originalText);
1345 $altText = normalizeText($altText);
1346
1347 $prevAltText = $altText;
1348 if ($caseInsensitive) {
1349 $altText = str_ireplace(trim($originalText), $translatedText, $altText);
1350 } else {
1351 $altText = str_replace(trim($originalText), $translatedText, $altText);
1352 }
1353 if ($altText !== $prevAltText) {
1354 break;
1355 }
1356 }
1357 $imgNode->alt = $altText;
1358 }
1359
1360 foreach ($htmlObj->find('img[title]') as $imgNode) {
1361 $titleText = $imgNode->title;
1362 if ($matchQuotes) {
1363 $titleText = str_ireplace('"', "'", $titleText);
1364 }
1365 foreach ($altTranslationsArray as $originalText => $translatedText) {
1366 $originalText = normalizeText($originalText);
1367 $titleText = normalizeText($titleText);
1368
1369 $prevTitleText = $titleText;
1370 if ($caseInsensitive) {
1371 $titleText = str_ireplace(trim($originalText), $translatedText, $titleText);
1372 } else {
1373 $titleText = str_replace(trim($originalText), $translatedText, $titleText);
1374 }
1375 if ($titleText !== $prevTitleText) {
1376 break;
1377 }
1378 }
1379 $imgNode->title = $titleText;
1380 }
1381
1382 // Check if also images 'src' are enabled to be translated
1383 if (! empty($settings['translate_srcimages'])) {
1384 foreach ($htmlObj->find('img[src]') as $imgNode) {
1385 $srcValue = ltrim($imgNode->src, '/');
1386
1387 if ($matchQuotes) {
1388 $srcValue = str_ireplace('"', "'", $srcValue);
1389 }
1390
1391 $translatedSrcValue = null;
1392 foreach ($altTranslationsArray as $originalText => $translatedText) {
1393 $originalText = normalizeText($originalText);
1394 $srcValue = normalizeText($srcValue);
1395
1396 $originalText = ltrim($originalText, '/');
1397 if ($caseInsensitive) {
1398 if (strcasecmp(trim($originalText), $srcValue) === 0) {
1399 $translatedSrcValue = $translatedText;
1400 break;
1401 }
1402 } else {
1403 if (trim($originalText) === $srcValue) {
1404 $translatedSrcValue = $translatedText;
1405 break;
1406 }
1407 }
1408 }
1409
1410 // If a translation was found, replace src and srcset
1411 if ($translatedSrcValue !== null) {
1412 // Replace the src attribute
1413 $imgNode->src = $translatedSrcValue;
1414
1415 // Check if srcset exists and replace it with the translated src value
1416 if (isset($imgNode->srcset)) {
1417 $imgNode->srcset = $translatedSrcValue;
1418 }
1419 }
1420 }
1421 }
1422 }
1423
1424 // Translate <meta name="description">
1425 foreach ($htmlObj->find('meta[name=description], meta[name=twitter:description], meta[property=og:description]') as $metaNode) {
1426 $contentValue = $metaNode->content;
1427
1428 // DECODIFICA HTML entities PRIMA del confronto
1429 $contentValue = html_entity_decode($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1430
1431 if ($matchQuotes) {
1432 $contentValue = str_ireplace('"', "'", $contentValue);
1433 }
1434
1435 foreach ($altTranslationsArray as $originalText => $translatedText) {
1436 $prev = $contentValue;
1437 if ($caseInsensitive) {
1438 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
1439 } else {
1440 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
1441 }
1442 if ($contentValue !== $prev) break;
1443 }
1444
1445 // RI-ENCODIFICA per l'HTML output (sicurezza)
1446 $contentValue = htmlspecialchars($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1447
1448 $metaNode->content = $contentValue;
1449 }
1450
1451 foreach ($htmlObj->find('meta[property=og:title]') as $metaOgTitleNode) {
1452 // Find the <title> tag to get the translated title
1453 $titleNode = $htmlObj->find('title', 0);
1454
1455 if ($titleNode) {
1456 // Get the translated title text
1457 $translatedTitle = $titleNode->plaintext;
1458
1459 // Set og:title with the same value as <title>
1460 $metaOgTitleNode->content = trim($translatedTitle);
1461 }
1462 }
1463
1464 // Translate <meta name="twitter:title"> with the same value as <title> tag
1465 foreach ($htmlObj->find('meta[name=twitter:title]') as $metaTwitterTitleNode) {
1466 // Find the <title> tag to get the translated title
1467 $titleNode = $htmlObj->find('title', 0);
1468
1469 if ($titleNode) {
1470 // Get the translated title text
1471 $translatedTitle = $titleNode->plaintext;
1472
1473 // Set twitter:title with the same value as <title>
1474 $metaTwitterTitleNode->content = trim($translatedTitle);
1475 }
1476 }
1477
1478 if ($bodyElement = $htmlObj->find('body', 0)) {
1479 $bodyElement->setAttribute('data-gptranslateskip', '1');
1480 $bodyElement->setAttribute ( 'data-gptranslateoriginalalias', htmlspecialchars($row['pagelink'], ENT_QUOTES, 'UTF-8') );
1481 }
1482
1483 $modifiedHtml = $htmlObj->save();
1484 $modifiedHtml = html_entity_decode($modifiedHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1485
1486 $html = $modifiedHtml;
1487 } catch (Exception $e) {
1488 // Handle exceptions as needed
1489 }
1490 } elseif ($settings['serverside_translations_method'] == 'strireplace') {
1491 // Solution 3: simplest approach, unconditional str_ireplace that could cause unintentional replacements
1492 try {
1493 $translationsArray = json_decode($row['translations'], true) ?? [];
1494 $altTranslationsArray = json_decode($row['alt_translations'] ?? '', true) ?: [];
1495
1496 // Do body page translations replacements
1497 uksort($translationsArray, function ($a, $b) {
1498 return strlen($b) - strlen($a);
1499 });
1500
1501 $caseInsensitive = !empty($settings['serverside_translations_caseinsensitive']);
1502 $matchQuotes = !empty($settings['serverside_translations_matchquotes']);
1503
1504 foreach ($translationsArray as $originalText => $translatedText) {
1505 $originalText = normalizeText($originalText);
1506 $html = normalizeText($html);
1507
1508 if ($caseInsensitive) {
1509 $html = str_ireplace(trim($originalText), $translatedText, $html);
1510
1511 // Check also if both single quotes or double quotes should be checked to replace
1512 if ($matchQuotes && strpos($originalText, "'") !== false) {
1513 $originalTextAlt = str_ireplace("'", '"', $originalText);
1514 $html = str_ireplace(trim($originalTextAlt), $translatedText, $html);
1515 }
1516 } else {
1517 $html = str_replace(trim($originalText), $translatedText, $html);
1518
1519 if ($matchQuotes && strpos($originalText, "'") !== false) {
1520 $originalTextAlt = str_replace("'", '"', $originalText);
1521 $html = str_replace(trim($originalTextAlt), $translatedText, $html);
1522 }
1523 }
1524 }
1525
1526 // Check if also images 'alt' are enabled to be translated
1527 if (!empty($settings['translate_altimages'])) {
1528 foreach ($altTranslationsArray as $originalAlt => $translatedAlt) {
1529 if ($matchQuotes) {
1530 $originalAlt = str_ireplace('"', "'", $originalAlt);
1531 }
1532 if ($caseInsensitive) {
1533 $html = str_ireplace('alt="' . trim($originalAlt) . '"', 'alt="' . $translatedAlt . '"', $html);
1534 $html = str_ireplace("alt='" . trim($originalAlt) . "'", "alt='" . $translatedAlt . "'", $html);
1535 } else {
1536 $html = str_replace('alt="' . trim($originalAlt) . '"', 'alt="' . $translatedAlt . '"', $html);
1537 $html = str_replace("alt='" . trim($originalAlt) . "'", "alt='" . $translatedAlt . "'", $html);
1538 }
1539 }
1540 }
1541
1542 $html = str_ireplace('<body', '<body data-gptranslateskip="1" data-gptranslateoriginalalias="' . $row['pagelink'] . '"', $html);
1543
1544 } catch (Exception $e) {
1545 // Handle exceptions as needed
1546 }
1547 }
1548
1549 return $html;
1550 } );
1551 } );
1552