{ id, item } * GET /library/{id} one saved template with its elements * DELETE /library/{id} delete a saved template (delete_post) * GET /library/remote starter kit index (remote URL or bundled library/kits.json) * GET /library/remote/{slug} one starter kit with its (sanitized) elements * POST /library/import { version, elements, pageSettings?, css? } -> sanitized copy (page JSON import) * GET /documents/{id}/export { version, elements, pageSettings, css, template, title } (edit_post) */ final class LibraryController { public const NS = 'zadex/v1'; public const EXPORT_VERSION = 1; private WidgetRegistry $widgets; private Store $store; private Kits $kits; public function __construct(WidgetRegistry $widgets, Store $store, Kits $kits) { $this->widgets = $widgets; $this->store = $store; $this->kits = $kits; } 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, '/library', [ [ '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, '/library/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'can_use'], 'args' => $id_args, ], [ 'methods' => 'DELETE', 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'can_delete'], 'args' => $id_args, ], ]); register_rest_route(self::NS, '/library/remote', [ 'methods' => 'GET', 'callback' => [$this, 'remote_index'], 'permission_callback' => [$this, 'can_use'], ]); register_rest_route(self::NS, '/library/remote/(?P[a-z0-9\-]+)', [ 'methods' => 'GET', 'callback' => [$this, 'remote_item'], 'permission_callback' => [$this, 'can_use'], ]); register_rest_route(self::NS, '/library/import', [ 'methods' => 'POST', 'callback' => [$this, 'import_page'], 'permission_callback' => [$this, 'can_use'], ]); register_rest_route(self::NS, '/documents/(?P\d+)/export', [ 'methods' => 'GET', 'callback' => [$this, 'export_document'], 'permission_callback' => [$this, 'can_edit_post'], 'args' => $id_args, ]); } public function can_use(): bool { return current_user_can('edit_posts'); } public function can_delete(WP_REST_Request $request): bool { $id = (int) $request['id']; if (!current_user_can('edit_posts')) { return false; } // Unknown ids fall through to the handler's 404; real templates need delete_post. return !$this->store->exists($id) || current_user_can('delete_post', $id); } public function can_edit_post(WP_REST_Request $request): bool { return current_user_can('edit_post', (int) $request['id']); } public function list_items(WP_REST_Request $request): WP_REST_Response { $type = is_string($request['type'] ?? null) ? (string) $request['type'] : ''; return new WP_REST_Response(['items' => $this->store->all(in_array($type, Store::TYPES, true) ? $type : '')]); } 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 template elements.', 'zadex'), ['status' => 400]); } $id = $this->store->create( is_string($body['title'] ?? null) ? $body['title'] : '', Store::sanitize_type($body['type'] ?? 'section'), $body['elements'], is_string($body['css'] ?? null) ? $body['css'] : '', is_array($body['pageSettings'] ?? null) ? $body['pageSettings'] : null, Store::sanitize_tags($body['tags'] ?? []) ); if (is_wp_error($id)) { return $id; } return new WP_REST_Response(['id' => $id, 'item' => $this->store->summary(get_post($id))], 201); } public function get_item(WP_REST_Request $request) { $item = $this->store->get((int) $request['id']); if ($item === null) { return new WP_Error('zadex_not_found', __('Template not found.', 'zadex'), ['status' => 404]); } return new WP_REST_Response($item); } public function delete_item(WP_REST_Request $request) { $id = (int) $request['id']; if (!$this->store->exists($id)) { return new WP_Error('zadex_not_found', __('Template not found.', 'zadex'), ['status' => 404]); } return new WP_REST_Response(['deleted' => $this->store->delete($id), 'id' => $id]); } public function remote_index(): WP_REST_Response { return new WP_REST_Response(['kits' => $this->kits->index()]); } public function remote_item(WP_REST_Request $request) { $kit = $this->kits->get((string) $request['slug']); if ($kit === null) { return new WP_Error('zadex_not_found', __('Kit not found.', 'zadex'), ['status' => 404]); } return new WP_REST_Response($kit); } /** Sanitizes an uploaded page export (the editor inserts or replaces with the result). */ public function import_page(WP_REST_Request $request) { $body = $request->get_json_params(); $clean = is_array($body) ? $this->sanitize_export($body) : null; if ($clean === null) { return new WP_Error('zadex_bad_import', __('This file is not a Zadex page export.', 'zadex'), ['status' => 400]); } return new WP_REST_Response($clean); } /** @return array{version:int, title:string, elements:array, pageSettings:array|object, css:string}|null */ public function sanitize_export(array $body): ?array { // Accept a page export ({version, elements}) or a bare document ({elements}) or the editor's `data` envelope. $elements = $body['elements'] ?? ($body['data']['elements'] ?? null); if (!is_array($elements)) { return null; } $sanitizer = new Sanitizer($this->widgets); $clean = $sanitizer->sanitize_elements($elements); if (!$clean && $elements) { return null; } $page = is_array($body['pageSettings'] ?? null) ? $body['pageSettings'] : []; // Page settings may be the stored envelope {settings, css}. if (isset($page['settings']) && is_array($page['settings'])) { $page = $page['settings']; } $page = $sanitizer->sanitize_settings_generic($page); if (isset($body['template']) && is_string($body['template']) && !isset($page['page_template'])) { $page['page_template'] = Templates::sanitize($body['template']); } return [ 'version' => self::EXPORT_VERSION, 'title' => is_string($body['title'] ?? null) ? sanitize_text_field($body['title']) : '', 'elements' => $clean, 'pageSettings' => $page ? $page : (object) [], 'css' => Document::sanitize_css(is_string($body['css'] ?? null) ? $body['css'] : ''), ]; } public function export_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(self::export_payload($id, (string) $post->post_title)); } /** The page JSON format shared by the REST export, the editor's "Export page" button and the site kit. */ public static function export_payload(int $id, string $title = ''): array { $data = Document::get_data($id); $page = Document::get_page_settings($id); $settings = isset($page['settings']) && is_array($page['settings']) ? $page['settings'] : $page; $template = Templates::get($id); if ($template !== '') { $settings['page_template'] = $template; } return [ 'version' => self::EXPORT_VERSION, 'generator' => 'zadex', 'title' => $title, 'elements' => $data['elements'], 'pageSettings' => $settings ? $settings : (object) [], 'css' => Document::get_css($id), 'template' => $template, ]; } }