{ html, css } (server-rendered previews) */ final class RestController { public const NS = 'zadex/v1'; private WidgetRegistry $widgets; private Renderer $renderer; private Settings $settings; public function __construct(WidgetRegistry $widgets, Renderer $renderer, Settings $settings) { $this->widgets = $widgets; $this->renderer = $renderer; $this->settings = $settings; } public function register(): void { add_action('rest_api_init', [$this, 'routes']); } public function routes(): void { $id_args = ['id' => ['validate_callback' => static fn($v): bool => is_numeric($v) && (int) $v > 0]]; register_rest_route(self::NS, '/documents/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_document'], 'permission_callback' => [$this, 'can_edit_post'], 'args' => $id_args, ], [ 'methods' => 'POST', 'callback' => [$this, 'save_document'], 'permission_callback' => [$this, 'can_edit_post'], 'args' => $id_args, ], ]); register_rest_route(self::NS, '/documents/(?P\d+)/revisions', [ 'methods' => 'GET', 'callback' => [$this, 'list_revisions'], 'permission_callback' => [$this, 'can_edit_post'], ]); register_rest_route(self::NS, '/documents/(?P\d+)/revisions/(?P\d+)', [ 'methods' => 'GET', 'callback' => [$this, 'get_revision'], 'permission_callback' => [$this, 'can_edit_post'], ]); register_rest_route(self::NS, '/documents/(?P\d+)/revisions/(?P\d+)/restore', [ 'methods' => 'POST', 'callback' => [$this, 'restore_revision'], 'permission_callback' => [$this, 'can_edit_post'], ]); register_rest_route(self::NS, '/widgets', [ 'methods' => 'GET', 'callback' => fn(): WP_REST_Response => new WP_REST_Response(['widgets' => $this->widgets->for_editor(), 'commonControls' => $this->widgets->common_controls()]), 'permission_callback' => static fn(): bool => current_user_can('edit_posts'), ]); register_rest_route(self::NS, '/global', [ [ 'methods' => 'GET', 'callback' => fn(): WP_REST_Response => new WP_REST_Response(['settings' => $this->settings->global(), 'css' => $this->settings->global_css(), 'breakpoints' => $this->settings->breakpoints()]), 'permission_callback' => static fn(): bool => current_user_can('edit_posts'), ], [ 'methods' => 'POST', 'callback' => [$this, 'save_global'], 'permission_callback' => static fn(): bool => current_user_can('manage_options') || current_user_can('edit_theme_options'), ], ]); register_rest_route(self::NS, '/render', [ 'methods' => 'POST', 'callback' => [$this, 'render'], 'permission_callback' => static fn(): bool => current_user_can('edit_posts'), ]); } public function can_edit_post(WP_REST_Request $request): bool { return current_user_can('edit_post', (int) $request['id']); } public function get_document(WP_REST_Request $request) { $id = (int) $request['id']; $post = get_post($id); if (!$post) { return new WP_Error('zadex_not_found', __('Post not found.', 'zadex'), ['status' => 404]); } return new WP_REST_Response([ 'id' => $id, 'title' => $post->post_title, 'status' => $post->post_status, 'data' => Document::get_data($id), 'css' => Document::get_css($id), 'pageSettings' => Document::get_page_settings($id), 'permalink' => get_permalink($id), 'modified' => $post->post_modified_gmt, ]); } public function save_document(WP_REST_Request $request) { $id = (int) $request['id']; $body = $request->get_json_params(); if (!is_array($body) || !isset($body['data']) || !is_array($body['data'])) { return new WP_Error('zadex_bad_request', __('Missing document data.', 'zadex'), ['status' => 400]); } // `zadex/documents/save_body` ($body, $id, $request): may adjust the body or reject the save with a WP_Error // (T170: content-only roles, Team\ContentGuard). $body = apply_filters('zadex/documents/save_body', $body, $id, $request); if (is_wp_error($body)) { return $body; } $status =isset($body['status']) && is_string($body['status']) ? $body['status'] : null; if ($status === 'publish' && !current_user_can('publish_post', $id)) { $status = null; } $result = Document::save( $id, $body['data'], isset($body['css']) && is_string($body['css']) ? $body['css'] : '', isset($body['pageSettings']) && is_array($body['pageSettings']) ? $body['pageSettings'] : null, $status ); if (is_wp_error($result)) { return $result; } // Page layout (Zadex Canvas / Full Width / theme default) lives in _wp_page_template, not in the document. $template = array_key_exists('template', $body) ? Templates::save($id, $body['template']) : Templates::get($id); $post = get_post($id); return new WP_REST_Response([ 'saved' => true, 'status' => $post ? $post->post_status : '', 'modified' => $post ? $post->post_modified_gmt : '', 'revisions'=> Document::revisions($id), 'template' => $template, ]); } public function list_revisions(WP_REST_Request $request): WP_REST_Response { return new WP_REST_Response(['revisions' => Document::revisions((int) $request['id'])]); } public function get_revision(WP_REST_Request $request) { $rev = (int) $request['rev']; $revision = wp_get_post_revision($rev); if (!$revision || (int) $revision->post_parent !== (int) $request['id']) { return new WP_Error('zadex_not_found', __('Revision not found.', 'zadex'), ['status' => 404]); } return new WP_REST_Response(['id' => $rev, 'data' => Document::revision_data($rev) ?? Document::empty_document()]); } public function restore_revision(WP_REST_Request $request) { $id = (int) $request['id']; $rev = (int) $request['rev']; $revision = wp_get_post_revision($rev); if (!$revision || (int) $revision->post_parent !== $id) { return new WP_Error('zadex_not_found', __('Revision not found.', 'zadex'), ['status' => 404]); } $data = Document::revision_data($rev); if ($data === null) { return new WP_Error('zadex_empty', __('Revision has no Zadex data.', 'zadex'), ['status' => 400]); } $css = (string) get_metadata('post', $rev, Document::META_CSS, true); $result = Document::save($id, $data, $css); if (is_wp_error($result)) { return $result; } return new WP_REST_Response(['restored' => true, 'data' => Document::get_data($id), 'css' => Document::get_css($id)]); } public function save_global(WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body) || !isset($body['settings']) || !is_array($body['settings'])) { return new WP_Error('zadex_bad_request', __('Missing settings.', 'zadex'), ['status' => 400]); } $sanitizer = new Sanitizer($this->widgets); $settings = $sanitizer->sanitize_settings_generic($body['settings']); $css = isset($body['css']) && is_string($body['css']) ? Document::sanitize_css($body['css']) : null; $this->settings->update_global($settings, $css); if (isset($body['breakpoints']) && is_array($body['breakpoints'])) { $this->settings->update(['breakpoints' => $this->sanitize_breakpoints($body['breakpoints'])]); } return new WP_REST_Response(['saved' => true, 'settings' => $this->settings->global(), 'breakpoints' => $this->settings->breakpoints()]); } public function render(WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body) || !isset($body['element']) || !is_array($body['element'])) { return new WP_Error('zadex_bad_request', __('Missing element.', 'zadex'), ['status' => 400]); } // Only set up post context for posts the user may edit (previews never expose other posts' data). if (!empty($body['postId']) && current_user_can('edit_post', (int) $body['postId'])) { \Zadex\Frontend\PostData::set(get_post((int) $body['postId'])); } $sanitizer = new Sanitizer($this->widgets); $element = $sanitizer->sanitize_element($body['element']); if ($element === null) { return new WP_Error('zadex_bad_element', __('Invalid element.', 'zadex'), ['status' => 400]); } $this->renderer->apply_conditions = false; // editor previews show every element (display conditions, T165) // Style tags never travel inside the HTML: the CSS of documents the element renders (embedded templates, // loop items, components) comes back as `css` (handle => css) and the canvas inserts it. [$html, $css] = \Zadex\Frontend\Styles::capture(fn(): string => $this->renderer->render_element($element)); return new WP_REST_Response(['html' => \Zadex\Frontend\Kses::restore(wp_kses($html, \Zadex\Frontend\Kses::allowed_html())), 'css' => (object) $css]); } private function sanitize_breakpoints(array $input): array { $out = []; foreach (Settings::default_breakpoints() as $key => $bp) { $in = isset($input[$key]) && is_array($input[$key]) ? $input[$key] : []; $out[$key] = [ // Same range as the admin settings screen (Admin::sanitize_settings). 'value' => isset($in['value']) ? max(200, min(6000, (int) $in['value'])) : $bp['value'], 'enabled' => $key === 'desktop' ? true : (isset($in['enabled']) ? (bool) $in['enabled'] : $bp['enabled']), ]; } return $out; } }