html/css) happens in the browser, performed by packages/core, * and the client submits { scene, html, css } together. PHP only stores. * * This is not a stopgap — the editor's save takes this same path from now on. * PHP never compiles the node tree, so there is only ever one rendering implementation. */ class Rest { const NS = 'aumframe/v1'; public static function boot(): void { add_action('rest_api_init', [self::class, 'routes']); } public static function routes(): void { register_rest_route(self::NS, '/rebaked', [ 'methods' => 'POST', 'callback' => static function () { update_option('aumframe_baked_with', AUMFRAME_VERSION); return new \WP_REST_Response(['ok' => true, 'engine' => AUMFRAME_VERSION], 200); }, 'permission_callback' => [self::class, 'can_edit'], ]); register_rest_route(self::NS, '/templates', [ [ 'methods' => 'GET', 'callback' => [self::class, 'index'], 'permission_callback' => [self::class, 'can_edit'], ], [ 'methods' => 'POST', 'callback' => [self::class, 'save'], 'permission_callback' => [self::class, 'can_edit'], ], ]); register_rest_route(self::NS, '/templates/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [self::class, 'show'], 'permission_callback' => [self::class, 'can_edit'], ], [ 'methods' => 'DELETE', 'callback' => [self::class, 'destroy'], 'permission_callback' => [self::class, 'can_edit'], ], ]); /* * The WP post this design is bound to — changing its title, address or publication status. * * Without this endpoint the editor can only ever "save", never "publish". * Saving writes the template row, while the WP page created by "new Aumframe page" is a `draft`: * the buyer finishes designing, saves, sees nothing on the front end, and has no idea they need to * go back to the Pages list and press publish — the flow simply breaks here (confirmed by testing * on 2026-08-28). Title and address are the same story: not editable in the editor, so they have * to save, return to the list and edit there, by which time they long since believed they were * finished. * * The capability check cannot stop at `edit_theme_options` (that is "may they use the design * tool"); it also has to establish whether this particular post is editable — see * `save_linked_post()`. */ register_rest_route(self::NS, '/linked-post/(?P\d+)', [ [ 'methods' => 'POST', 'callback' => [self::class, 'save_linked_post'], 'permission_callback' => [self::class, 'can_edit'], ], ]); /* * The component library. GET fetches everything (the editor needs it all at startup, since * insertion is synchronous and cannot stop halfway to make a request); POST publishes one; DELETE * removes one. */ register_rest_route(self::NS, '/components', [ [ 'methods' => 'GET', 'callback' => [self::class, 'lib_index'], 'permission_callback' => [self::class, 'can_edit'], ], [ 'methods' => 'POST', 'callback' => [self::class, 'lib_save'], 'permission_callback' => [self::class, 'can_edit'], ], ]); register_rest_route(self::NS, '/components/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [self::class, 'lib_delete'], 'permission_callback' => [self::class, 'can_edit'], ]); /* * Rendering the canvas with real data. * * The editor bakes HTML locally (fast, carrying data-af-id, and interaction depends on it), then * sends the output here for PHP to fill in real data along exactly the front end's path: * `Renderer::render_baked()` — expand loops → expand conditions → fill bindings → expand * shortcodes. * * Why not reimplement it in JS: that is the "JS editor / PHP front end" pair drifting apart, * precisely what this architecture set out to avoid. One extra round trip buys "the canvas and the * front end come from the same bytes". */ /* * Per-language layout variants * * Two steps on purpose. `prepare` does everything PHP can do — extract, translate, apply — and * hands back a finished scene; the browser bakes it and posts the output to `variant`. Baking is * the browser's job because it is the only implementation there is (see the note at the top). */ register_rest_route(self::NS, '/i18n/report', [ 'methods' => 'GET', 'callback' => [self::class, 'i18n_report'], 'permission_callback' => [self::class, 'can_edit'], ]); register_rest_route(self::NS, '/i18n/prepare', [ 'methods' => 'POST', 'callback' => [self::class, 'i18n_prepare'], 'permission_callback' => [self::class, 'can_edit'], ]); register_rest_route(self::NS, '/i18n/variant', [ 'methods' => 'POST', 'callback' => [self::class, 'i18n_variant'], 'permission_callback' => [self::class, 'can_edit'], ]); register_rest_route(self::NS, '/render-preview', [ 'methods' => 'POST', 'callback' => [self::class, 'render_preview'], 'permission_callback' => [self::class, 'can_edit'], ]); register_rest_route(self::NS, '/templates/(?P\d+)/revisions', [ 'methods' => 'GET', 'callback' => [self::class, 'revisions'], 'permission_callback' => [self::class, 'can_edit'], ]); register_rest_route(self::NS, '/revisions/(?P\d+)', [ 'methods' => 'GET', 'callback' => [self::class, 'revision'], 'permission_callback' => [self::class, 'can_edit'], ]); } /** * Change the post this design is bound to: title, address, publication status. * * Two layers of capability check. `can_edit` only establishes "this person may use the design * tool" (`edit_theme_options`), while whether they may change this particular post is a separate * question — an editor who can change the theme cannot necessarily change somebody else's locked * page. Skipping the second layer is a privilege-escalating write. * * Status may only move between draft and publish. The editor should not be able to trash * content or make it private: those actions have confirmations and undo in WP's own interface, while * here they would be one button with no way back. */ public static function save_linked_post(\WP_REST_Request $req) { $id = (int) $req['id']; if (!$id || !current_user_can('edit_post', $id)) { return new \WP_Error('forbidden', __('You cannot edit this content.', 'aumframe'), ['status' => 403]); } $post = get_post($id); if (!$post) { return new \WP_Error('not_found', __('That content no longer exists.', 'aumframe'), ['status' => 404]); } $patch = ['ID' => $id]; $title = $req->get_param('title'); if (is_string($title) && trim($title) !== '') { $patch['post_title'] = sanitize_text_field($title); } $slug = $req->get_param('slug'); if (is_string($slug) && trim($slug) !== '') { // sanitize_title handles spaces and case; wp_update_post appends its own suffix on a collision $patch['post_name'] = sanitize_title($slug); } $status = $req->get_param('status'); if ($status === 'publish' || $status === 'draft') { if ($status === 'publish' && !current_user_can('publish_post', $id)) { return new \WP_Error('forbidden', __('You cannot publish this content.', 'aumframe'), ['status' => 403]); } $patch['post_status'] = $status; } if (count($patch) > 1) { $res = wp_update_post(wp_slash($patch), true); if (is_wp_error($res)) { return new \WP_Error('save_failed', $res->get_error_message(), ['status' => 500]); } } $post = get_post($id); return [ 'id' => $id, 'title' => $post->post_title, // Read the slug back from the database, not from the request — WP appends a suffix on a // collision (about → about-2), and the front end has to see the real result, or the address it // displays and the one that actually opens are two different things 'slug' => $post->post_name, 'status' => $post->post_status, 'permalink' => (string) (get_permalink($id) ?: ''), ]; } public static function can_edit(): bool { return current_user_can('edit_theme_options'); } /** * Fetch one template, with its scene — the editor needs it to start work. * * The baked output is not returned: the editor re-bakes from the scene anyway, so sending it would be * pure wasted bandwidth. */ public static function show(\WP_REST_Request $request) { /* * This response must never be cached. * * The editor fetches the design through it on every open. Cached once — by the browser or by the * host's proxy layer — the symptom is "it saved and reloading the editor puts it back to the * previous version", however many times you reload, while the front end is new (it reads the * baked output along a different path). * The client also sets `cache: 'no-store'`; both layers are needed: the client governs the browser * and this one governs intermediate proxies and cache entries that already exist. */ nocache_headers(); $row = Storage::get((int) $request['id']); if (!$row) { return new \WP_Error('af_not_found', __('That template no longer exists.', 'aumframe'), ['status' => 404]); } unset($row['baked_html'], $row['baked_css']); return new \WP_REST_Response($row, 200); } /** The history list. Without scenes — each is tens of KB, and the list only needs times and authors. */ /** * Render the canvas with real data. * * The editor sends its locally baked HTML here and this runs `Renderer::render_baked()` — **exactly * the same function as the front end**: expand loops → expand conditions → fill bindings → expand * shortcodes. So what appears on the canvas is what the front end will render, including loops * genuinely expanding into N rows. * * Do not rewrite this logic in JS. That is the "JS editor / PHP front end" pair drifting * apart — what this architecture has been avoiding since day one. An extra round trip is the cheaper * price. */ public static function render_preview(\WP_REST_Request $request): \WP_REST_Response { $html = (string) $request->get_param('html'); $queries = (array) ($request->get_param('queries') ?: []); $tplId = (int) $request->get_param('tpl'); if ($html === '') { return new \WP_REST_Response(['html' => '', 'context' => 'empty'], 200); } $row = $tplId ? Storage::get($tplId) : null; $rules = $row['rules'] ?? []; if (is_string($rules)) { $rules = json_decode($rules, true) ?: []; } $sample = self::sample_query_args(is_array($rules) ? $rules : []); /* * Temporarily swap the global `$wp_query`. * * Conditional tags (`is_singular()`, `is_tax()` and friends) and `source:'main'` all read it — * without the swap the canvas gets the context of "an admin REST request", which has neither a * current post nor a current term, so every binding fails to resolve and the whole trip is wasted. * * It must be swapped back in a finally: other hooks still run after a REST request. */ try { [$out, $extraStyles] = self::with_sample_query($sample['args'], static function () use ($html, $queries) { $ctx = DataContext::current(); /* * Styles a shortcode enqueues while rendering (the theme's form CSS) — the canvas has to load * them too. Their handles are *registered* on `wp_enqueue_scripts`, which a REST request never * fires, so the front end's enqueue pipeline runs here first: without it `wp_enqueue_style('aumframe-forms')` * refers to nothing and resolves to no URL (measured 2026-09-11: form rendered, inputs white). * Nothing is printed by a REST response, so the queues need no restoring. */ try { do_action('wp_enqueue_scripts'); } catch (\Throwable $e) { /* a plugin's front-end enqueue must not sink the preview */ } $stylesBefore = wp_styles()->queue; $out = Renderer::render_baked($html, $queries, $ctx, ['keepHidden' => true]); $extraStyles = Editor::style_urls(array_values(array_diff(wp_styles()->queue, $stylesBefore))); return [$out, $extraStyles]; }); } catch (\Throwable $e) { // The canvas must not go blank because preview data failed — fall back to the unfilled // version and say so return new \WP_REST_Response([ 'html' => $html, 'context' => 'error', 'message' => $e->getMessage(), ], 200); } return new \WP_REST_Response([ 'html' => $out, 'context' => $sample['label'], 'stylesheets' => $extraStyles, ], 200); } /** * Derive a representative query from the template's rules. * * The same derivation as the editor's Preview button: whichever class of URL the template serves, find * a real sample so the canvas shows real content rather than empty placeholders. * * @return array{args: ?array, label: string} */ public static function sample_query_args(array $rules): array { $kind = (string) ($rules['kind'] ?? ''); $type = (string) ($rules['post_type'] ?? ''); if ($kind === 'singular') { $ids = (array) ($rules['post_ids'] ?? []); if ($ids) { $id = (int) $ids[0]; return ['args' => ['p' => $id, 'post_type' => get_post_type($id) ?: 'post'], 'label' => sprintf('singular: %s', get_the_title($id))]; } $latest = get_posts(['post_type' => $type ?: 'post', 'numberposts' => 1, 'post_status' => 'publish', 'fields' => 'ids']); if ($latest) { return ['args' => ['p' => (int) $latest[0], 'post_type' => $type ?: 'post'], 'label' => sprintf('singular: %s', get_the_title((int) $latest[0]))]; } } if ($kind === 'archive') { /* * Term archives first: one archive rule serves both a content-type archive and a term archive, * and a term archive has the fuller context (it has a `term` as well). Seeing what the * "has a term" branch looks like carries more information than the content-type archive. */ /* * Only when the rule names a taxonomy. A post-type-only rule (`/people/`) used to borrow the * type's first taxonomy for a "fuller" sample — but that term archive is usually claimed by a * taxonomy template of its own (`/expertise/corporate/` → expertise-archive), so the canvas * showed a page this template never serves (wysiwyg-audit 2026-09-13, people-archive). */ $tax = (string) ($rules['taxonomy'] ?? ''); if ($tax !== '' && taxonomy_exists($tax)) { $terms = get_terms(['taxonomy' => $tax, 'number' => 1, 'hide_empty' => true, 'fields' => 'all']); if (!is_wp_error($terms) && $terms) { $t = $terms[0]; /* * The query var, not the taxonomy name: `category` and `post_tag` are queried as * `category_name` / `tag` (a custom taxonomy's var is usually its own name). With the * taxonomy name as the key WP_Query silently ignored the filter, so the canvas listed * every post while Preview opened the category with two (2026-09-13, insights archive). */ $var = self::taxonomy_query_var($tax); return ['args' => [$var => $t->slug, 'post_type' => $type ?: 'any'], 'label' => sprintf('archive: %s', $t->name)]; } } if ($type === 'post' && get_option('show_on_front') === 'page' && (int) get_option('page_for_posts')) { /* * The blog index is the "posts page": querying it by pagename is what makes WP_Query set * is_home + is_posts_page and take that page as the queried object — which is what the * front end does, and what the header's "current item" needs (a bare post_type=post query * has queried_object_id 0, so "Insights" was never current on the canvas). */ $slug = (string) get_post_field('post_name', (int) get_option('page_for_posts')); if ($slug !== '') { return ['args' => ['pagename' => $slug], 'label' => 'archive: posts page']; } } if ($type !== '') { return ['args' => ['post_type' => $type], 'label' => sprintf('archive: %s', $type)]; } } if ($kind === 'search') { return ['args' => ['s' => 'test'], 'label' => 'search']; } if ($kind === 'notfound') { // A 404 has no query of its own; the caller turns this into WP_Query::set_404() return ['args' => ['__404' => true], 'label' => '404']; } /* * The site shell, the homepage, and anything that cannot be derived: use the homepage. * The shell is neither an archive nor a single to begin with; all it needs is `site.*`. */ $front = (int) get_option('page_on_front'); if ($front) { return ['args' => ['page_id' => $front], 'label' => 'front page']; } return ['args' => null, 'label' => 'no sample']; } /** The WP_Query var a taxonomy is queried by (`category` → `category_name`, `post_tag` → `tag`). */ public static function taxonomy_query_var(string $tax): string { if ($tax === 'category') { return 'category_name'; } if ($tax === 'post_tag') { return 'tag'; } $obj = get_taxonomy($tax); return $obj && is_string($obj->query_var) && $obj->query_var !== '' ? $obj->query_var : $tax; } /** * Swap the global query for the sample's (see sample_query_args) and run `$fn`; always swap back. * Shared by the preview endpoint and the editor's shell render, so both see the same sample. */ public static function with_sample_query(?array $args, callable $fn) { global $wp_query, $wp_the_query, $post; $oldQuery = $wp_query; $oldMain = $wp_the_query; $oldPost = $post; try { if ($args !== null) { /* * Built as the main query: `$wp_the_query` points at it before it runs, so * `is_main_query()` holds inside `pre_get_posts` and the theme's / plugins' adjustments * (which post types a search covers, per-page counts) apply exactly as on the front * end. A plain `new WP_Query($args)` skipped them — the search sample listed one * result on the canvas and three on the front (2026-09-13, mach1 search). */ $q = new \WP_Query(); $wp_the_query = $q; $wp_query = $q; if (!empty($args['__404'])) { $q->set_404(); } else { $q->query($args); if ($q->have_posts()) { $q->the_post(); } } } return $fn(); } finally { $wp_query = $oldQuery; $wp_the_query = $oldMain; $post = $oldPost; wp_reset_postdata(); } } public static function revisions(\WP_REST_Request $request): \WP_REST_Response { $rows = Storage::revisions((int) $request['id']); foreach ($rows as &$row) { $user = $row['author_id'] ? get_userdata((int) $row['author_id']) : null; $row['author'] = $user ? $user->display_name : ''; } return new \WP_REST_Response($rows, 200); } /** * Fetch one history entry's content. * * There is no separate "roll back" endpoint: the scene is handed to the editor, the user sees it on the * canvas, and then goes through the normal save. So a rollback is recorded in the history too — **a * rollback is undoable**. Overwriting on the server directly would leave a wrong rollback beyond * rescue. */ public static function revision(\WP_REST_Request $request) { $row = Storage::revision((int) $request['rev']); if (!$row) { return new \WP_Error('af_not_found', __('That revision no longer exists.', 'aumframe'), ['status' => 404]); } return new \WP_REST_Response($row, 200); } public static function index(): \WP_REST_Response { $rows = Storage::published(); foreach ($rows as &$row) { // The list does not need the baked output sent back; it can be large unset($row['baked_html'], $row['baked_css'], $row['scene']); } return new \WP_REST_Response($rows, 200); } public static function lib_index(): \WP_REST_Response { return new \WP_REST_Response(Library::all(), 200); } public static function lib_save(\WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body) || empty($body['def'])) { return new \WP_Error('aumframe_bad_component', __('The component definition is missing.', 'aumframe'), ['status' => 400]); } // def is stored verbatim: the client sends a JSON string, and the moment PHP re-serialises it the // empty objects inside become empty arrays (see the explanation in save()) $raw = is_string($body['def']) ? $body['def'] : wp_json_encode($body['def']); $def = json_decode((string) $raw, true); if (!is_array($def) || empty($def['root']) || empty($def['nodes'])) { return new \WP_Error('aumframe_bad_component', __('That component definition is not valid.', 'aumframe'), ['status' => 400]); } $id = Library::save([ 'slug' => $body['slug'] ?? '', 'name' => $body['name'] ?? ($def['name'] ?? ''), 'category' => $body['category'] ?? 'general', 'def' => $raw, /* * The markup is baked by the editor and filtered here; the stylesheet is generated * here from the definition, so no CSS is taken from the request. The breakpoints * decide which media queries are emitted and are validated before use. */ 'baked_html' => Baked::html((string) ($body['baked_html'] ?? '')), 'baked_css' => Baked::css_from_component($def, $body['breakpoints'] ?? null), ]); return new \WP_REST_Response(['id' => $id], 200); } public static function lib_delete(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response(['ok' => Library::delete((int) $request['id'])], 200); } public static function save(\WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body) || empty($body['scene'])) { return new \WP_Error('aumframe_bad_payload', __('The scene is missing.', 'aumframe'), ['status' => 400]); } /* * The scene is stored exactly as the client sent it, with only a decoded copy used for * validation. * * The result of "decode then re-encode" must not be stored: PHP's json_decode(..., true) collapses * an empty object `{}` and an empty array `[]` into the same thing (an empty PHP array), and * re-encoding always emits `[]`. So a node with `style: {}` becomes `style: []` after one save, and * `fill` and `filter` happen to be both style keys and methods on Array.prototype: * * style.filter -> Array.prototype.filter -> the inspector crashes while rendering * style.fill -> Array.prototype.fill -> `fill.type` is undefined, silently falling back to * "no fill" (harder to trace) * * This layer's only job is to store (see the convention at the top of this file); it must not * rewrite the content. */ $raw = is_string($body['scene']) ? $body['scene'] : wp_json_encode($body['scene']); $scene = json_decode((string) $raw, true); if (!is_array($scene) || empty($scene['root'])) { return new \WP_Error('aumframe_bad_scene', __('That is not a valid Aumframe scene.', 'aumframe'), ['status' => 400]); } if (!isset($body['baked_html'])) { return new \WP_Error( 'aumframe_not_baked', __('The baked markup is missing, so there would be nothing to serve.', 'aumframe'), ['status' => 400] ); } $payload = [ 'id' => $body['id'] ?? null, 'scene' => $raw, 'baked_html' => Baked::html((string) $body['baked_html']), 'baked_css' => Baked::css_from_scene($scene), 'schema_version' => $scene['schemaVersion'] ?? 1, ]; // Metadata is passed on only when it was actually supplied. // Filling gaps with `?? default` turns "not mentioned" into "set to empty", so a save meaning to // update only the canvas would wipe the routing rules and the page would stop being taken over. foreach (['slug', 'title', 'kind', 'rules', 'priority', 'status'] as $key) { if (array_key_exists($key, $body)) { $payload[$key] = $body[$key]; } } /* * Optimistic locking: a stale copy must not overwrite newer data. * * The worst case observed online on 2026-08-31: the editor received the previous scene from * the browser cache, the user pressed save, and **the server's correct data was overwritten by the * stale copy** — the surface symptom being "I save and a refresh puts it back", while in fact every * save was a rollback. * * The caching cause is fixed (`no-store` on the client plus `nocache_headers()` here), but any * situation that hands the editor a stale copy reproduces it: two tabs open on one design, a network * retry, someone else editing at the same time. So the test cannot be "the caching is fixed" but * send the version stamp you read, and refuse when it does not match. * * Requests without `updated_at` are still accepted — older editors and scripted calls have to keep * working. */ if (!empty($body['updated_at']) && !empty($body['id'])) { $current = Storage::get((int) $body['id']); if ($current && !empty($current['updated_at']) && $current['updated_at'] !== $body['updated_at'] ) { return new \WP_Error( 'aumframe_stale', __('This design was changed elsewhere after you opened it. Reload the page to get the latest version — saving now would overwrite those changes.', 'aumframe'), ['status' => 409, 'serverUpdatedAt' => $current['updated_at']] ); } } $id = Storage::save($payload); /* * A failed database write must return 500, never 200. * * This used to answer `ok: true` whether or not anything was stored, so the editor showed "Saved" — * the user refreshed to find every change gone, with nothing reported anywhere along the way, and * could only conclude the editor was broken. * MySQL's own words are carried back: this class of failure is environmental (a scene too large for * `max_allowed_packet`, a connection timeout…), and without the specific reason neither they nor we * have anywhere to start. */ if (!$id) { return new \WP_Error( 'aumframe_save_failed', /* translators: %s: the reason the design could not be saved. */ sprintf(__('The design could not be saved: %s', 'aumframe'), Storage::last_error()), ['status' => 500] ); } // Return the new version stamp to the client; the next save has to carry it $saved = Storage::get($id); return new \WP_REST_Response([ 'id' => $id, 'ok' => true, 'updated_at' => $saved['updated_at'] ?? '', ], 200); } public static function destroy(\WP_REST_Request $request): \WP_REST_Response { Storage::delete((int) $request['id']); return new \WP_REST_Response(['ok' => true], 200); } /** Which templates are translated into a language, and which have drifted since. */ public static function i18n_report(\WP_REST_Request $req) { $lang = sanitize_text_field((string) $req->get_param('lang')); if ($lang === '') { return new \WP_Error('aumframe_no_lang', __('A language code is required.', 'aumframe'), ['status' => 400]); } return new \WP_REST_Response(TemplateI18n::report($lang), 200); } /** Translate a template's layout strings and return the scene for the browser to bake. */ public static function i18n_prepare(\WP_REST_Request $req) { $body = (array) $req->get_json_params(); $id = (int) ($body['template_id'] ?? 0); $lang = sanitize_text_field((string) ($body['lang'] ?? '')); if (!$id || $lang === '') { return new \WP_Error('aumframe_bad_request', __('A template id and a language code are required.', 'aumframe'), ['status' => 400]); } $result = TemplateI18n::prepare($id, $lang); return is_wp_error($result) ? $result : new \WP_REST_Response($result, 200); } /** Store what the browser baked. */ public static function i18n_variant(\WP_REST_Request $req) { $body = (array) $req->get_json_params(); $id = (int) ($body['template_id'] ?? 0); $lang = sanitize_text_field((string) ($body['lang'] ?? '')); if (!$id || $lang === '') { return new \WP_Error('aumframe_bad_request', __('A template id and a language code are required.', 'aumframe'), ['status' => 400]); } /* * The same refusal the template save makes, for the same reason: without the baked output * there is nothing to serve, and storing the row anyway would report the language as done. */ if (!isset($body['baked_html']) || trim((string) $body['baked_html']) === '') { return new \WP_Error( 'aumframe_not_baked', __('The baked markup is missing, so there would be nothing to serve.', 'aumframe'), ['status' => 400] ); } $raw = is_string($body['scene'] ?? null) ? $body['scene'] : wp_json_encode($body['scene'] ?? null); $scene = json_decode((string) $raw, true); if (!is_array($scene) || empty($scene['root'])) { return new \WP_Error('aumframe_bad_scene', __('That is not a valid Aumframe scene.', 'aumframe'), ['status' => 400]); } $ok = TemplateI18n::store( $id, $lang, (string) $raw, Baked::html((string) $body['baked_html']), Baked::css_from_scene($scene), sanitize_text_field((string) ($body['source_hash'] ?? '')), is_array($body['source_strings'] ?? null) ? $body['source_strings'] : [] ); return $ok ? new \WP_REST_Response(['stored' => true], 200) : new \WP_Error('aumframe_store_failed', __('Could not store the variant.', 'aumframe'), ['status' => 500]); } }