.` — e.g. `sh_t12.props.content`. Node ids are stable across a rebuild of the same * scene, so a translation survives the author moving a section; they change only when a node is deleted * and remade, which is exactly when its translation *should* be considered gone. */ class SceneStrings { /** Properties that hold language, by node type. */ private const TRANSLATABLE = [ 'Text' => ['content'], 'Image' => ['alt'], 'Embed' => ['consentLabel', 'consentNote'], ]; private const SHORTCODE_TEXT_ATTRS = ['label', 'placeholder', 'prev', 'next', 'light', 'dark']; /** * Pull the translatable strings out of a scene. * * @param array $scene Decoded scene. * @return array path => source text */ public static function extract(array $scene): array { $out = []; foreach (($scene['nodes'] ?? []) as $id => $node) { $type = $node['type'] ?? ''; foreach (self::TRANSLATABLE[$type] ?? [] as $prop) { $value = $node['props'][$prop] ?? ''; if (!is_string($value) || trim($value) === '') { continue; } // Bound text is the buyer's content, not the layout's. See the note above. if (isset($node['bindings']['props.' . $prop])) { continue; } // No letters means nothing to translate — a dash, an arrow, a number. if (!preg_match('/\p{L}/u', $value)) { continue; } $out[$id . '.props.' . $prop] = $value; } if (($node['type'] ?? '') === 'Shortcode' && !isset($node['bindings']['props.code'])) { $code = $node['props']['code'] ?? ''; if (is_string($code) && $code !== '') { foreach (self::SHORTCODE_TEXT_ATTRS as $attr) { if (preg_match('/\b' . preg_quote($attr, '/') . '\s*=\s*"([^"]+)"/', $code, $m) && preg_match('/\p{L}/u', $m[1])) { $out[$id . '.props.code@' . $attr] = $m[1]; } } } } } return $out; } /** * Put translations back into a copy of the scene. * * Only paths that are still present are applied. A stored translation for a node the author has * since deleted is dropped rather than resurrected. * * @param array $scene Decoded scene. * @param array $translations path => translated text. * @return array The translated scene. */ public static function apply(array $scene, array $translations): array { foreach ($translations as $path => $text) { if (!is_string($text) || $text === '') { continue; } $parts = explode('.', $path); if (count($parts) !== 3 || $parts[1] !== 'props') { continue; } [$id, , $prop] = $parts; if (strpos($prop, 'code@') === 0) { $attr = substr($prop, 5); $code = $scene['nodes'][$id]['props']['code'] ?? null; if (!is_string($code) || !in_array($attr, self::SHORTCODE_TEXT_ATTRS, true)) { continue; } if (($scene['nodes'][$id]['type'] ?? '') !== 'Shortcode') { continue; } $scene['nodes'][$id]['props']['code'] = preg_replace_callback( '/(\b' . preg_quote($attr, '/') . '\s*=\s*")([^"]*)(")/', static function () use ($text) { return func_get_arg(0)[1] . str_replace('"', '"', $text) . '"'; }, $code, 1 ); continue; } if (!isset($scene['nodes'][$id]['props'][$prop])) { continue; } if (!in_array($prop, self::TRANSLATABLE[$scene['nodes'][$id]['type'] ?? ''] ?? [], true)) { continue; // Only ever write where we would have read. } $scene['nodes'][$id]['props'][$prop] = $text; } return $scene; } /** * A fingerprint of a scene's translatable strings. * * Hashes the strings, not the scene. Moving a section, restyling it or renaming a layer all * change the scene and change nothing a translator would do differently — hashing the whole thing * would mark every translation stale on every save, and a staleness flag that is always on is one * nobody looks at. * * @param array $scene Decoded scene. * @return string */ public static function hash(array $scene): string { $strings = self::extract($scene); ksort($strings); return sha1((string) wp_json_encode($strings)); } }