full item (201) * GET /components/{id} edit_posts { id, title, kind, usage, modified, elements, css, props } * PUT /components/{id} edit_post {id} { title?, elements?, css?, props? } -> full item * DELETE /components/{id} delete_post {id} { deleted, id, usage } * * Elements are sanitized like any document (Document::save, which also fires `zadex/document/saved`: usage index, * props sync). A component can never contain itself (400 `zadex_component_recursive`). */ final class ComponentsController { public const NS = 'zadex/v1'; private WidgetRegistry $widgets; public function __construct(WidgetRegistry $widgets) { $this->widgets = $widgets; } 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, '/components', [ ['methods' => 'GET', 'callback' => [$this, 'list_items'], 'permission_callback' => [$this, 'can_use']], ['methods' => 'POST', 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'can_use']], ]); register_rest_route(self::NS, '/components/(?P\d+)', [ ['methods' => 'GET', 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'can_use'], 'args' => $id_args], ['methods' => 'PUT', 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'can_edit'], 'args' => $id_args], ['methods' => 'DELETE', 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'can_delete'], 'args' => $id_args], ]); } public function can_use(): bool { return current_user_can('edit_posts'); } /** Unknown ids fall through to the handler's 404; real components need edit_post. */ public function can_edit(WP_REST_Request $request): bool { $id = (int) $request['id']; if (!current_user_can('edit_posts')) { return false; } return !ComponentPost::exists($id) || current_user_can('edit_post', $id); } public function can_delete(WP_REST_Request $request): bool { $id = (int) $request['id']; if (!current_user_can('edit_posts')) { return false; } return !ComponentPost::exists($id) || current_user_can('delete_post', $id); } public function list_items(): WP_REST_Response { $items = []; foreach (ComponentPost::all() as $post) { $items[] = ComponentPost::summary($post); } return new WP_REST_Response(['items' => $items]); } public function get_item(WP_REST_Request $request) { $item = ComponentPost::full((int) $request['id']); if ($item === null) { return self::not_found(); } return new WP_REST_Response($item); } public function create_item(WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body) || !isset($body['elements']) || !is_array($body['elements'])) { return new WP_Error('zadex_bad_request', __('Missing component elements.', 'zadex'), ['status' => 400]); } $title = is_string($body['title'] ?? null) ? trim(sanitize_text_field($body['title'])) : ''; if ($title === '') { return new WP_Error('zadex_bad_request', __('A component needs a name.', 'zadex'), ['status' => 400]); } $elements = (new Sanitizer($this->widgets))->sanitize_elements($body['elements']); if (!$elements) { return new WP_Error('zadex_bad_request', __('The component has no valid elements.', 'zadex'), ['status' => 400]); } $id = wp_insert_post(wp_slash([ 'post_type' => ComponentPost::POST_TYPE, 'post_title' => $title, 'post_status' => 'publish', 'post_content' => '', ]), true); if (is_wp_error($id)) { return $id; } $id = (int) $id; $saved = $this->store($id, $elements, is_string($body['css'] ?? null) ? $body['css'] : '', $body['props'] ?? []); if (is_wp_error($saved)) { wp_delete_post($id, true); return $saved; } return new WP_REST_Response(ComponentPost::full($id), 201); } public function update_item(WP_REST_Request $request) { $id = (int) $request['id']; if (!ComponentPost::exists($id)) { return self::not_found(); } $body = $request->get_json_params(); if (!is_array($body)) { return new WP_Error('zadex_bad_request', __('Missing component data.', 'zadex'), ['status' => 400]); } if (isset($body['title']) && is_string($body['title'])) { $title = trim(sanitize_text_field($body['title'])); if ($title !== '') { wp_update_post(wp_slash(['ID' => $id, 'post_title' => $title])); } } $elements = ComponentPost::tree($id); if (isset($body['elements']) && is_array($body['elements'])) { $elements = (new Sanitizer($this->widgets))->sanitize_elements($body['elements']); if (!$elements) { return new WP_Error('zadex_bad_request', __('The component has no valid elements.', 'zadex'), ['status' => 400]); } } $css = isset($body['css']) && is_string($body['css']) ? $body['css'] : Document::get_css($id); $props = array_key_exists('props', $body) ? $body['props'] : ComponentPost::props($id); $saved = $this->store($id, $elements, $css, $props); if (is_wp_error($saved)) { return $saved; } return new WP_REST_Response(ComponentPost::full($id)); } public function delete_item(WP_REST_Request $request) { $id = (int) $request['id']; if (!ComponentPost::exists($id)) { return self::not_found(); } $usage = Usage::count($id); $deleted = (bool) wp_delete_post($id, true); ComponentPost::reset_cache($id); return new WP_REST_Response(['deleted' => $deleted, 'id' => $id, 'usage' => $usage]); } /** * Stores tree + CSS + props through Document::save (sanitizer, revision, `zadex/document/saved`). Props travel in * the page settings (`component_props`), the same place the editor keeps them when the component is opened * as its own document; Components::document_saved copies them to `_zadex_component_props`. * * @return true|WP_Error */ private function store(int $id, array $elements, string $css, $props) { if (Components::contains(Usage::component_ids($elements), $id)) { return new WP_Error('zadex_component_recursive', __('A component cannot contain itself.', 'zadex'), ['status' => 400]); } $props = ComponentPost::sanitize_props($props); $page = ['settings' => [ComponentPost::PAGE_KEY => $props], 'css' => '']; $result = Document::save($id, ['version' => Document::VERSION, 'elements' => $elements], $css, $page); if (is_wp_error($result)) { return $result; } // Also done by the `zadex/document/saved` hook; repeated here so the response never depends on hook order. Components::sync($id, Document::get_data($id)['elements'] ?? [], $props, $this->widgets); return true; } private static function not_found(): WP_Error { return new WP_Error('zadex_not_found', __('Component not found.', 'zadex'), ['status' => 404]); } }