post_type, ['page', 'post'], true)) { return $states; } if (self::template_for($post->ID) === null) { return $states; } $states['aumframe'] = __('Aumframe', 'aumframe'); return $states; } /** * What identity this post is routed under on the front end. * * Structurally the same as Router::context(), except that one reads the current request * (is_front_page() and friends) while this answers the same question for an arbitrary post — * every row of the admin list asks it once, with no front-end query to read. * * The order matches Router::context(): the homepage takes precedence over a single post. The * homepage is itself a page, both tests pass, and whichever comes first decides. */ private static function route_context(\WP_Post $post): array { if ($post->post_type === 'page') { if ((int) get_option('page_on_front') === $post->ID) { return ['kind' => 'front_page']; } if ((int) get_option('page_for_posts') === $post->ID) { return ['kind' => 'archive', 'post_type' => 'post']; } /* * A content type has attached its archive to this page's path — **which is exactly how * WooCommerce's shop page works** (has_archive can be a string; see the note on the same * thing in DataContext). * * Such a page is not rendered as a page at all on the front end; it goes through the * content type's archive. Missing it produces exactly the homepage symptom: the buyer clicks * into the "Bikes" page, receives a blank canvas, lays it out, and the front end does not * change. * * It deliberately does not recognise Woo (not orchestrating third-party plugins is * this project's explicit stance); it relies on WordPress's own has_archive contract, so * anyone attaching one this way is recognised. */ if ($post->post_name !== '') { foreach (get_post_types(['has_archive' => true], 'objects') as $pt) { if (is_string($pt->has_archive) && $pt->has_archive === $post->post_name) { return ['kind' => 'archive', 'post_type' => $pt->name]; } } } } return [ 'kind' => 'singular', 'post_type' => $post->post_type, 'post_id' => $post->ID, ]; } /* * These two return the raw URL (an `&` is an `&`); escaping belongs to each output point: HTML * uses esc_url, JS uses it directly. * * wp_nonce_url is not used: it runs esc_html internally and returns a string containing `&`. * That string is fine written straight into an href (the browser decodes the entity), but putting it * into JSON for JS to use as an address is broken — the parameter becomes `amp;post`. */ public static function edit_url(int $post_id, bool $own = false): string { return add_query_arg(array_filter([ 'action' => self::ACTION, 'post' => $post_id, 'own' => $own ? '1' : null, '_wpnonce' => wp_create_nonce(self::ACTION . '_' . $post_id), ]), admin_url('admin-post.php')); } public static function new_url(): string { return add_query_arg([ 'action' => self::ACTION_NEW, '_wpnonce' => wp_create_nonce(self::ACTION_NEW), ], admin_url('admin-post.php')); } /* Row actions in the list */ public static function row_action(array $actions, \WP_Post $post): array { if (!current_user_can('edit_post', $post->ID) || !current_user_can('edit_theme_options')) { return $actions; } if (!in_array($post->post_type, ['page', 'post'], true)) { return $actions; } $tpl = self::template_for($post->ID); /* * "Edit" opens whatever template routes here, and that is routinely not this page alone — see * Scope. The marker is short on purpose; the editor itself names every page affected, which is * where the buyer is about to change something. */ if ($tpl === null) { $label = __('Design with Aumframe', 'aumframe'); } elseif (Scope::is_shared($tpl)) { $label = __('Edit with Aumframe (shared design)', 'aumframe'); } else { $label = __('Edit with Aumframe', 'aumframe'); } $actions['aumframe'] = sprintf('%s', esc_url(self::edit_url($post->ID)), $label); // A page on a shared design gets the second door: a design of its own, started from the shared one if ($tpl !== null && Scope::is_shared($tpl) && !self::own_template_for($post->ID)) { $actions['aumframe_own'] = sprintf('%s', esc_url(self::edit_url($post->ID, true)), __('Own Aumframe design', 'aumframe')); } return $actions; } /* Handlers */ /** * Open a post's design (creating it first when necessary) and go straight to the editor. * To the user this is one step; the template row created along the way is not something they should * have to know about. */ public static function handle_edit(): void { $post_id = isset($_GET['post']) ? (int) $_GET['post'] : 0; check_admin_referer(self::ACTION . '_' . $post_id); if (!$post_id || !current_user_can('edit_post', $post_id) || !current_user_can('edit_theme_options')) { wp_die(esc_html__('You do not have permission to do that.', 'aumframe')); } $post = get_post($post_id); if (!$post) { wp_die(esc_html__('That page no longer exists.', 'aumframe')); } $tpl = self::template_for($post_id); $own = self::own_template_for($post_id); if ($own) { $tpl_id = (int) $own['id']; } elseif ($tpl && empty($_GET['own'])) { // The shared design that shows this page — with the editor's banner naming everything it affects $tpl_id = (int) $tpl['id']; } else { // A design of its own, started from the design that currently shows it (the fallback's // page head, body column and all), so the new page looks like the rest of the site instead of // an empty frame — and saving it touches this page only. $tpl_id = self::create_for($post, $tpl); } wp_safe_redirect(Editor::url($tpl_id)); exit; } /** One step: create the WP page, create the template row, open the editor. Gutenberg is not involved. */ public static function handle_new(): void { check_admin_referer(self::ACTION_NEW); if (!current_user_can('publish_pages') || !current_user_can('edit_theme_options')) { wp_die(esc_html__('You do not have permission to do that.', 'aumframe')); } $post_id = wp_insert_post([ 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => __('Untitled page', 'aumframe'), ], true); if (is_wp_error($post_id)) { /* translators: %s: the error WordPress returned when creating the page. */ wp_die(esc_html(sprintf(__('Could not create the page: %s', 'aumframe'), $post_id->get_error_message()))); } $tpl_id = self::create_for(get_post($post_id)); wp_safe_redirect(Editor::url($tpl_id)); exit; } /** * Create an empty design for a post. * * It starts with a full-width root Frame whose height follows its content — an empty scene leaves the * editor with nothing to grab (no nodes means nothing to select and nowhere to insert). */ private static function create_for(\WP_Post $post, ?array $from = null): int { $copied = $from ? json_decode((string) $from['scene'], true) : null; if (is_array($copied) && !empty($copied['root']) && !empty($copied['nodes'])) { /* * A copy of the shared design's scene, rules narrowed to this one post. Node ids stay as they * are (they only need to be unique within a scene); the components travel with it. Baked * output is not copied — the editor bakes on save, and until then the shared design keeps * serving the page (both match; the specific rule wins in Router::match). */ return Storage::save([ 'slug' => 'post-' . $post->ID, /* translators: %d: post id, used when the page has no title yet. */ 'title' => $post->post_title ?: sprintf(__('Page #%d', 'aumframe'), $post->ID), 'kind' => 'page', 'rules' => ['kind' => 'singular', 'post_type' => $post->post_type, 'post_ids' => [$post->ID]], 'scene' => $copied, 'baked_html' => (string) ($from['baked_html'] ?? ''), 'baked_css' => (string) ($from['baked_css'] ?? ''), ]); } $scene = [ 'schemaVersion' => 1, 'root' => 'n_root', 'breakpoints' => [ ['id' => 'desktop', 'max' => null], ['id' => 'tablet', 'max' => 1024], ['id' => 'mobile', 'max' => 640], ], 'tokens' => new \stdClass(), 'nodes' => [ 'n_root' => [ 'id' => 'n_root', 'type' => 'Frame', 'name' => __('Page', 'aumframe'), 'children' => [], 'style' => [ 'layout' => 'stack', 'direction' => 'vertical', 'gap' => 0, 'width' => ['mode' => 'fill'], // An empty page at height:fit computes to zero and leaves the canvas a void with nothing to // grab — a minimum height gives it something, and it grows once content goes in 'height' => ['mode' => 'fit', 'min' => 400], ], ], ], ]; return Storage::save([ 'slug' => 'post-' . $post->ID, /* translators: %d: post id, used when the page has no title yet. */ 'title' => $post->post_title ?: sprintf(__('Page #%d', 'aumframe'), $post->ID), 'kind' => 'page', 'rules' => ['kind' => 'singular', 'post_type' => $post->post_type, 'post_ids' => [$post->ID]], 'scene' => $scene, 'baked_html' => '', 'baked_css' => '', ]); } /* The Gutenberg edit screen */ /** * A page taken over by Aumframe must say so inside Gutenberg. * Otherwise the user sees an empty editor and concludes the content is gone — the classic early * Elementor complaint. */ public static function edit_screen_notice(\WP_Post $post): void { if (!in_array($post->post_type, ['page', 'post'], true) || !current_user_can('edit_theme_options')) { return; } $tpl = self::template_for($post->ID); /* * Kept raw here and escaped at each output below. It was escaped once on this line, which is * correct but invisible to a reader three printf() calls away — and one of those calls passed * it as a second, unescaped-looking argument beside an esc_url()'d one. */ $url = self::edit_url($post->ID); if ($tpl && self::own_template_for($post->ID)) { printf( '
' . '

%2$s %3$s

' . '%4$s
', esc_url($url), esc_html__('This page is designed in Aumframe.', 'aumframe'), esc_html__('The editor below does not affect what visitors see.', 'aumframe'), esc_html__('Edit with Aumframe', 'aumframe') ); } elseif ($tpl) { printf( '
' . '

%2$s %3$s

' . '%4$s %6$s
', esc_url(self::edit_url($post->ID, true)), /* translators: %s: title of the shared design this page is rendered through. */ esc_html(sprintf(__('This page is shown through the shared “%s” design.', 'aumframe'), $tpl['title'])), esc_html__('What you write below appears where that design shows the page body. To lay this page out on its own, give it a design of its own.', 'aumframe'), esc_html__('Design this page on its own', 'aumframe'), esc_url($url), esc_html__('Edit the shared design', 'aumframe') ); } else { printf( '

%2$s

', esc_url($url), esc_html__('Design with Aumframe', 'aumframe') ); } } /** The same notice for the block editor — through core/notices, because notices printed by PHP do not * appear in Gutenberg. */ public static function block_editor_notice(): void { $post = get_post(); if (!$post || !in_array($post->post_type, ['page', 'post'], true)) { return; } if (!current_user_can('edit_theme_options') || !current_user_can('edit_post', $post->ID)) { return; } wp_enqueue_script( 'aumframe-editor-link', AUMFRAME_URL . 'assets/editor-link.js', ['wp-data', 'wp-notices', 'wp-dom-ready'], aumframe_asset_ver('assets/editor-link.js'), true ); /* * The copy is translated here, not in JS. * * editor-link.js is hand-written and not bundled (see its own file header), and wiring wp.i18n * and a separate JSON translation bundle to it for three sentences is not worth it. Passed from * PHP, there is exactly one translation source — the .po. Two sources end in "half the admin in * one language and half in another". */ $shared = self::template_for($post->ID); $own = $shared ? self::own_template_for($post->ID) : null; wp_add_inline_script( 'aumframe-editor-link', 'window.AUMFRAME_LINK = ' . wp_json_encode([ // On a shared design the button forks: a page of its own, started from the shared one 'editUrl' => self::edit_url($post->ID, $shared !== null && $own === null), 'hasDesign' => $own !== null, 'message' => $own ? __('This page is designed in Aumframe. The block editor below does not affect what visitors see.', 'aumframe') : ($shared /* translators: %s: title of the shared design this page is rendered through. */ ? sprintf(__('This page is shown through the shared “%s” design: what you write here appears where that design shows the page body. To lay this page out on its own, give it a design of its own.', 'aumframe'), $shared['title']) : __('This page does not have an Aumframe design yet.', 'aumframe')), 'action' => $own ? __('Edit with Aumframe', 'aumframe') : ($shared ? __('Design this page on its own', 'aumframe') : __('Design with Aumframe', 'aumframe')), ]) . ';', 'before' ); } /* The button at the top of the Pages list */ public static function list_screen_button(): void { $screen = get_current_screen(); if (!$screen || $screen->id !== 'edit-page' || !current_user_can('edit_theme_options')) { return; } printf( '
%2$s
', esc_url(self::new_url()), esc_html__('+ New Aumframe page', 'aumframe') ); } }