true, 'type' => 'string', 'show_in_rest' => false, 'revisions_enabled' => true, 'auth_callback' => static fn(): bool => current_user_can('edit_posts'), ]); } } public static function revision_meta_keys(array $keys, string $post_type): array { return array_unique(array_merge($keys, [self::META_DATA, self::META_CSS, self::META_MODE, self::META_PAGE])); } public static function revisions_to_keep(int $num, \WP_Post $post): int { return self::is_builder($post->ID) ? max($num, 30) : $num; } public static function is_builder(int $post_id): bool { return get_post_meta($post_id, self::META_MODE, true) === 'builder'; } public static function empty_document(): array { return ['version' => self::VERSION, 'elements' => []]; } public static function get_data(int $post_id): array { $raw = get_post_meta($post_id, self::META_DATA, true); $decoded = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; if (!is_array($decoded) || !isset($decoded['elements']) || !is_array($decoded['elements'])) { return self::empty_document(); } $decoded['version'] = (int) ($decoded['version'] ?? self::VERSION); return $decoded; } public static function get_css(int $post_id): string { $css = (string) get_post_meta($post_id, self::META_CSS, true); // Older saves could carry per-element custom CSS (removed feature): never print it. $raw = get_post_meta($post_id, self::META_DATA, true); if ($css !== '' && is_string($raw) && str_contains($raw, '_custom_css')) { $css = self::strip_legacy_custom_css($css, self::get_data($post_id)['elements']); } return $css; } /** * Removes the CSS that the old per-element "Custom CSS" control (`_custom_css`, `{{WRAPPER}}` = `.zx-`) * appended to the generated page CSS. The control no longer exists; re-saving the page regenerates clean CSS. * * @param array $elements */ public static function strip_legacy_custom_css(string $css, array $elements): string { foreach ($elements as $el) { if (!is_array($el)) { continue; } $id = isset($el['id']) && is_string($el['id']) ? $el['id'] : ''; foreach ((isset($el['settings']) && is_array($el['settings']) ? $el['settings'] : []) as $key => $value) { if ($id === '' || !is_string($value) || !is_string($key) || !preg_match('/^_custom_css(@[a-z_]+)?$/', $key)) { continue; } $snippet = trim(str_replace('{{WRAPPER}}', '.zx-' . $id, $value)); foreach (array_unique([$snippet, trim(self::sanitize_css($snippet))]) as $tail) { if ($tail !== '') { $css = str_replace($tail, '', $css); } } } if (!empty($el['children']) && is_array($el['children'])) { $css = self::strip_legacy_custom_css($css, $el['children']); } } return $css; } public static function get_page_settings(int $post_id): array { $raw = get_post_meta($post_id, self::META_PAGE, true); $decoded = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; return is_array($decoded) ? $decoded : []; } /** * One page-level setting (desktop/normal value). Page settings are stored as `{settings, css}`; a bare * settings object (older saves, the dev harness) is accepted too. */ public static function page_setting(int $post_id, string $key, $default = null) { $page = self::get_page_settings($post_id); $settings = isset($page['settings']) && is_array($page['settings']) ? $page['settings'] : $page; return array_key_exists($key, $settings) ? $settings[$key] : $default; } /** Whether the page asked the theme's title to be hidden (Page panel "Hide page title"). */ public static function hide_title(int $post_id): bool { return !empty(self::page_setting($post_id, 'hide_title')); } /** * @return true|WP_Error */ public static function save(int $post_id, array $data, string $css, ?array $page_settings = null, ?string $status = null) { $post = get_post($post_id); if (!$post) { return new WP_Error('zadex_not_found', __('Post not found.', 'zadex'), ['status' => 404]); } $sanitizer = new Sanitizer(Plugin::instance()->widgets); $clean = $sanitizer->sanitize_document($data); $css = self::sanitize_css($css); update_post_meta($post_id, self::META_DATA, wp_slash(wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))); update_post_meta($post_id, self::META_CSS, wp_slash($css)); update_post_meta($post_id, self::META_MODE, 'builder'); if ($page_settings !== null) { update_post_meta($post_id, self::META_PAGE, wp_slash(wp_json_encode($sanitizer->sanitize_settings_generic($page_settings), JSON_UNESCAPED_UNICODE))); } $update = ['ID' => $post_id]; if ($status !== null && in_array($status, ['publish', 'draft', 'pending', 'private'], true)) { $update['post_status'] = $status; } // Touch the post so that a revision (with meta) is created and modified date updates. $update['post_modified'] = current_time('mysql'); $update['post_modified_gmt'] = current_time('mysql', true); $result = wp_update_post(wp_slash($update), true); if (is_wp_error($result)) { return $result; } if (!wp_is_post_revision($post_id)) { wp_save_post_revision($post_id); } do_action('zadex/document/saved', $post_id, $clean, $css); return true; } /** @return array */ public static function revisions(int $post_id): array { $out = []; foreach (wp_get_post_revisions($post_id, ['posts_per_page' => 40]) as $rev) { if (get_metadata('post', $rev->ID, self::META_DATA, true) === '') { continue; } $author = get_userdata((int) $rev->post_author); $out[] = [ 'id' => $rev->ID, 'date' => get_date_from_gmt($rev->post_modified_gmt, 'c'), 'human' => human_time_diff(strtotime($rev->post_modified_gmt), time()), 'author' => $author ? $author->display_name : '', ]; } return $out; } public static function revision_data(int $revision_id): ?array { $raw = get_metadata('post', $revision_id, self::META_DATA, true); $decoded = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; return is_array($decoded) ? $decoded : null; } public static function sanitize_css(string $css): string { $css = str_replace(["\0"], '', $css); // Block external resources and expressions; generated CSS never needs them. $css = preg_replace('/expression\s*\(/i', '', $css) ?? $css; $css = preg_replace('/@import\b[^;]*;?/i', '', $css) ?? $css; // url(javascript:…), url(vbscript:…) and url(data:text/html…) are never legitimate here. $css = preg_replace('#url\s*\(\s*[\'"]?\s*(?:javascript:|vbscript:|data:\s*text/html)[^)]*\)#i', 'url()', $css) ?? $css; return str_ireplace('