tag without re-parsing. WP row actions // are always a single label - safe to regex. $actions[ $key ] = preg_replace( '/]*\btarget=)/i', 'get_node( $node_id ); if ( ! $node ) { continue; } $meta = is_array( $node->meta ) ? $node->meta : array(); $meta['target'] = '_blank'; $meta['rel'] = trim( ( $meta['rel'] ?? '' ) . ' noopener' ); $wp_admin_bar->add_node( array( 'id' => $node->id, 'meta' => $meta, ) ); } } /** * Return the configured headless frontend origin (no trailing slash), or '' * if not set. Cached per-request. */ function hatch_frontend_origin(): string { static $cached = null; if ( null !== $cached ) { return $cached; } $url = trim( (string) get_option( 'hatch_frontend_url', '' ) ); $cached = $url ? untrailingslashit( $url ) : ''; return $cached; } /** * Rewrite a WP origin URL ($url) so its path is served by the headless * frontend. Returns the original URL when no frontend is configured. */ function hatch_rewrite_to_frontend( string $url ): string { $front = hatch_frontend_origin(); if ( '' === $front || '' === $url ) { return $url; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return $url; } $home = wp_parse_url( home_url() ); if ( ! is_array( $home ) || empty( $home['host'] ) ) { return $url; } if ( strcasecmp( $parts['host'], $home['host'] ) !== 0 ) { return $url; } $path = isset( $parts['path'] ) ? $parts['path'] : '/'; $query = isset( $parts['query'] ) ? '?' . $parts['query'] : ''; return $front . $path . $query; } // Editor "View Post" / list "View" - point straight at the frontend. add_filter( 'post_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'page_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'post_type_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'attachment_link', 'hatch_rewrite_to_frontend', 20 ); // Editor "Preview" button. WP appends a preview nonce; the headless site // won't honor it, but we still want the new tab to land on the live URL // rather than wp-admin. For draft/scheduled posts WP returns the admin // preview URL - leave those untouched. add_filter( 'preview_post_link', function ( $link, $post ) { if ( ! $post || 'publish' !== get_post_status( $post ) ) { return $link; } return hatch_rewrite_to_frontend( (string) $link ); }, 20, 2 ); // Category / tag / CPT archive links + term links - same treatment. add_filter( 'term_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'post_type_archive_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'author_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'day_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'month_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'year_link', 'hatch_rewrite_to_frontend', 20 ); add_filter( 'rest_prepare_post', 'hatch_align_generated_slug_to_saved', 10, 2 ); add_filter( 'rest_prepare_page', 'hatch_align_generated_slug_to_saved', 10, 2 ); /** * Gutenberg's URL preview tooltip uses `permalink_template` + `generated_slug` * from the REST response - and `generated_slug` is auto-regenerated from the * post TITLE every request, ignoring the user's manually-saved slug. Result: * a post titled "Edge E Test Post" with saved slug `edge-e-test` shows a * tooltip URL ending in `edge-e-test-post`, which 404s on the headless * frontend. * * Force `generated_slug` to mirror the actual saved `post_name` once a post * has a real slug. New (unsaved) drafts still get the title-derived default. * * @param WP_REST_Response|mixed $response Prepared response. * @param WP_Post $post Post being prepared. * @return WP_REST_Response|mixed */ function hatch_align_generated_slug_to_saved( $response, $post ) { if ( ! $response instanceof WP_REST_Response || ! $post ) { return $response; } // Skip unsaved drafts so Gutenberg's title-derived slug preview still works // while the user is typing the first title. if ( empty( $post->post_name ) || 'auto-draft' === $post->post_name || 'auto-draft' === $post->post_status ) { return $response; } $data = $response->get_data(); if ( isset( $data['generated_slug'] ) ) { $data['generated_slug'] = $post->post_name; } if ( isset( $data['permalink_template'] ) ) { // Rebuild the template URL from the saved permalink so Gutenberg's // preview tooltip reflects the same URL as the View Post button. $data['permalink_template'] = (string) get_permalink( $post ); } $response->set_data( $data ); return $response; } // Same fix for any public CPT registered with show_in_rest. add_action( 'rest_api_init', function () { foreach ( get_post_types( array( 'public' => true, 'show_in_rest' => true, ), 'names' ) as $pt ) { if ( in_array( $pt, array( 'post', 'page', 'attachment' ), true ) ) { continue; } add_filter( "rest_prepare_{$pt}", 'hatch_align_generated_slug_to_saved', 10, 2 ); } }, 99 ); /** * v0.50.12 - Editor toolbar "View Post" / "Preview" → new tab. The Gutenberg * editor renders these as plain tags read from the REST `link` field; we * inject a small JS that flips them to target="_blank" once mounted, plus the * classic editor's #post-preview / #view-post-btn anchors. */ /** * v0.50.18 - Sync Hatch design tokens to the WordPress frontend so the * active WP theme respects the user's picks (max width, brand colors, * fonts). Without this, a user who installs Hatch but doesn't * connect a frontend would see WordPress render with the theme's * own width / colors, ignoring everything they set in the Hatch admin - * exactly the "no conflict between Hatch + WP theme" guarantee the user * asked for. * * Adds ONE inline style block to the front end. Includes: * 1. Every CSS var the Astro frontend uses (so any Hatch block on a WP- * rendered page picks them up automatically). * 2. An opt-in sync rule (off by default) that hard-pins `max-width: var(--hatch-max-width)` on * the most common WP theme container classes (`.entry-content`, * `.site-content`, `.wp-site-blocks`, `.wp-block-post-content`, `main` * and `article`). Themes that already use narrower widths are * unaffected because of `max-width` semantics. */ add_action( 'wp_enqueue_scripts', 'hatch_sync_design_tokens_to_wp_frontend', 5 ); function hatch_sync_design_tokens_to_wp_frontend(): void { $brand = class_exists( 'Hatch_Design_Loader' ) ? Hatch_Design_Loader::admin_brand( (array) get_option( 'hatch_design_brand', array() ) ) : (array) get_option( 'hatch_design_brand', array() ); $layout = (array) get_option( 'hatch_design_layout', array() ); // Same defaults as the frontend, so a WP-rendered page matches the deployed site. $primary = hatch_css_color( $brand['primary'] ?? '', '#7ebc11' ); $accent = hatch_css_color( $brand['accent'] ?? '', '#0a0a0a' ); $bg = hatch_css_color( $brand['background'] ?? '', '#ffffff' ); $font_h = hatch_css_font_name( (string) get_option( 'hatch_design_font_heading', 'Inter' ) ); $font_b = hatch_css_font_name( (string) get_option( 'hatch_design_font_body', 'Inter' ) ); // Width normalisation: tolerate legacy "1320px" or canonical "1320". $max_raw = $layout['max_width'] ?? ( $layout['maxWidth'] ?? '1160' ); $max_w = (int) preg_replace( '/[^0-9]/', '', (string) $max_raw ); if ( $max_w < 320 || $max_w > 3000 ) { $max_w = 1160; } $density_map = array( 'compact' => '0.75', 'comfortable' => '1', 'spacious' => '1.25', ); $density_key = strtolower( (string) ( $layout['density'] ?? 'comfortable' ) ); $density = $density_map[ $density_key ] ?? '1'; $radius_map = array( 'sharp' => '4px', 'smooth' => '10px', 'extra' => '20px', ); $rounded = strtolower( (string) ( $layout['rounded'] ?? $layout['roundness'] ?? 'smooth' ) ); if ( 'default' === $rounded ) { $rounded = 'smooth'; } if ( 'extraround' === $rounded ) { $rounded = 'extra'; } $radius = $radius_map[ $rounded ] ?? '10px'; $css = ':root{'; $css .= '--hatch-primary:' . $primary . ';'; $css .= '--hatch-accent:' . $accent . ';'; $css .= '--hatch-bg-design:' . $bg . ';'; $css .= '--hatch-font-heading:"' . $font_h . '",ui-sans-serif,system-ui,sans-serif;'; $css .= '--hatch-font-body:"' . $font_b . '",ui-sans-serif,system-ui,sans-serif;'; $css .= '--hatch-density:' . $density . ';'; $css .= '--hatch-radius:' . $radius . ';'; $css .= '--hatch-max-width:' . $max_w . 'px;'; $css .= '}'; // Opt-in only: re-laying-out the active theme's containers is never done by default. // Owners who want the Hatch width on WP-rendered pages turn it on with the // `hatch_sync_wp_layout` option or filter. Themes with a narrower width still win. /** * Filters whether Hatch pins its content width on the active theme's containers. * * @param bool $enabled Default false, from the `hatch_sync_wp_layout` option. */ if ( apply_filters( 'hatch_sync_wp_layout', (bool) get_option( 'hatch_sync_wp_layout', false ) ) ) { $css .= '.entry-content,.site-content,.wp-site-blocks,.wp-block-post-content,'; $css .= 'main.wp-block-group,article.post,article.page,.hatch-post-container{'; $css .= 'max-width:var(--hatch-max-width,1160px);margin-left:auto;margin-right:auto;}'; } // Light mode only. Form controls and scrollbars always use light colours. $css .= 'html{color-scheme:light;}'; wp_register_style( 'hatch-design-tokens', false, array(), HATCH_VERSION ); wp_enqueue_style( 'hatch-design-tokens' ); wp_add_inline_style( 'hatch-design-tokens', $css ); } /** * Validate a CSS colour taken from an option. * * @param mixed $value Stored value. * @param string $fallback Colour used when the value is not a safe colour. * @return string */ function hatch_css_color( $value, string $fallback ): string { $value = trim( (string) $value ); $hex = sanitize_hex_color( $value ); if ( $hex ) { return $hex; } if ( preg_match( '/^(rgb|hsl)a?\(\s*[0-9.%\s,\/-]+\)$/i', $value ) ) { return $value; } return $fallback; } /** * Reduce a stored font family to characters that cannot break out of a CSS string. * * @param string $value Stored font name. * @return string */ function hatch_css_font_name( string $value ): string { $clean = trim( (string) preg_replace( '/[^A-Za-z0-9 _-]/', '', $value ) ); return '' === $clean ? 'Inter' : $clean; } add_action( 'admin_enqueue_scripts', 'hatch_force_editor_view_new_tab' ); /** * Open the "View" and "Preview" links of the post editor in a new tab. * * @param string $hook_suffix Current admin page. * @return void */ function hatch_force_editor_view_new_tab( $hook_suffix ): void { $origin = hatch_frontend_origin(); if ( '' === $origin || ! in_array( $hook_suffix, array( 'post.php', 'post-new.php', 'edit.php' ), true ) ) { return; } $host = (string) wp_parse_url( $origin, PHP_URL_HOST ); wp_register_script( 'hatch-editor-links', '', array(), HATCH_VERSION, true ); wp_enqueue_script( 'hatch-editor-links' ); $script = '(function () {' . 'var host = ' . wp_json_encode( $host ) . ';' . 'var apply = function () {' . 'document.querySelectorAll("a#view-post-btn, a#post-preview, .editor-post-preview-dropdown__button-external, a.components-button").forEach(function (a) {' . 'if (a.target === "_blank") { return; }' . 'if (!a.matches("a#view-post-btn, a#post-preview, .editor-post-preview-dropdown__button-external") && (!host || (a.getAttribute("href") || "").indexOf(host) === -1)) { return; }' . 'a.target = "_blank";' . 'a.rel = (a.rel ? a.rel + " " : "") + "noopener";' . '});' . '};' . 'apply();' . 'new MutationObserver(apply).observe(document.body, { childList: true, subtree: true });' . '})();'; wp_add_inline_script( 'hatch-editor-links', $script ); } /** * v0.50.8 - CORS for /hatch/v1/* so the deployed Cloudflare Worker can * POST comments / form submissions back to WordPress from a different origin * without browser preflight blocking. Edge D resolved. * * Echoes the request Origin only when it exactly matches the saved frontend * URL or image proxy URL. Wildcarding "*" is unsafe with credentials; the * frontend never sends credentials anyway, but scoping to known frontend * origins is the right hygiene. */ add_action( 'rest_api_init', 'hatch_cors_headers', 15 ); function hatch_cors_headers(): void { remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' ); add_filter( 'rest_pre_serve_request', function ( $value, $result = null, $request = null ) { // Only adjust headers for our own namespace. The matched route is used // rather than the URL, so a custom REST prefix, a subdirectory install, // index.php?rest_route= and plain permalinks all behave the same way. $route = $request instanceof WP_REST_Request ? $request->get_route() : ''; if ( 0 !== strpos( $route, '/hatch/v1/' ) && '/hatch/v1' !== $route ) { // Not a Hatch route: keep WordPress core's own CORS handling. return rest_send_cors_headers( $value ); } $origin = esc_url_raw( Hatch_Request::server( 'HTTP_ORIGIN' ) ); $frontend = untrailingslashit( (string) get_option( 'hatch_frontend_url', '' ) ); $proxy = untrailingslashit( (string) get_option( 'hatch_image_proxy_url', '' ) ); $allowed = array_filter( array( $frontend, $proxy ) ); $is_allowed = false; if ( '' !== $origin ) { foreach ( $allowed as $a ) { if ( $origin === $a ) { $is_allowed = true; break; } } // No tenant wildcard for workers.dev: any Cloudflare Workers URL would // have matched, which meant an attacker could deploy a hostile // worker on those platforms and receive CORS approval from any // Hatch site that had never set hatch_frontend_url. Post-deploy // the wizard writes hatch_frontend_url explicitly; the explicit // allowlist above is now the only accepted path. } if ( $is_allowed ) { header( 'Access-Control-Allow-Origin: ' . $origin ); header( 'Vary: Origin' ); header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' ); header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' ); header( 'Access-Control-Max-Age: 600' ); } return $value; }, 15, 3 ); } // v0.38 - Dashboard widget removed per user feedback. The WP /wp-admin/ home // is the user's own dashboard; Hatch shouldn't crowd it. Status lives on the // Hatch admin page only. /** * v0.35 - On activation, auto-mirror hatch_frontend_url into hatch_image_proxy_url * so the image proxy uses your own domain by default (enterprise pattern, no * third-party host in your HTML). User doesn't have to set this manually. */ register_activation_hook( __FILE__, 'hatch_on_activation' ); function hatch_on_activation(): void { $frontend = trim( (string) get_option( 'hatch_frontend_url', '' ) ); $current = trim( (string) get_option( 'hatch_image_proxy_url', '' ) ); if ( $frontend && '' === $current ) { update_option( 'hatch_image_proxy_url', untrailingslashit( $frontend ) ); } } // Module loader - reads feature flags from DB and conditionally includes classes. require_once HATCH_PLUGIN_DIR . 'includes/class-request.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-module-loader.php'; // V0.1 core. require_once HATCH_PLUGIN_DIR . 'includes/class-detector.php'; /** * Whether the current admin request is one of Hatch's own screens. * * Hatch shows notices only on its own screens, never across the rest of wp-admin. * * @return bool */ function hatch_is_own_admin_screen(): bool { if ( ! is_admin() ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; return 'hatch' === $page || 0 === strpos( $page, 'hatch-' ) || 0 === strpos( $page, 'hatch_' ); } require_once HATCH_PLUGIN_DIR . 'includes/class-rest-permission.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-security.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-rest-api.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-revalidate.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-media-rewriter.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-hardening.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-seo-bridge.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-forms-bridge.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-auth.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-rankready-bridge.php'; // V0.27 - nav menu passthrough. require_once HATCH_PLUGIN_DIR . 'includes/class-menus-bridge.php'; // v0.50.32 - always register primary + footer menu locations from the plugin // so both slots are pickable in the Content tab regardless of the active theme. add_action( 'after_setup_theme', array( 'Hatch_Menus_Bridge', 'register_locations' ), 4 ); // V0.34 - REST endpoints for reading and saving Hatch options. require_once HATCH_PLUGIN_DIR . 'includes/class-options-rest.php'; // V0.2 hardening + health. require_once HATCH_PLUGIN_DIR . 'includes/class-acf-bridge.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-cpt-scanner.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-login-hardening.php'; // Encrypted credential store for the Cloudflare API token. require_once HATCH_PLUGIN_DIR . 'includes/class-credential-store.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-diagnostic.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-domain-check.php'; // V0.4 - WP-CLI commands (loaded only when WP_CLI is defined). if ( defined( 'WP_CLI' ) && WP_CLI ) { require_once HATCH_PLUGIN_DIR . 'includes/class-cli.php'; } // V0.22 - integrations + headless comments + headless forms + frontend redirect. require_once HATCH_PLUGIN_DIR . 'includes/class-integrations.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-headless-comments.php'; require_once HATCH_PLUGIN_DIR . 'includes/class-headless-forms.php'; // Sends visitors of the raw WordPress address to the connected frontend. Plain plugin behaviour, no theme is installed or switched. require_once HATCH_PLUGIN_DIR . 'includes/class-frontend-redirect.php'; Hatch_Frontend_Redirect::boot(); // Privacy policy text plus personal-data export and erasure for stored form submissions. require_once HATCH_PLUGIN_DIR . 'includes/class-privacy.php'; Hatch_Privacy::boot(); // The optional URL rewriter mu-plugin follows the same lifecycle: written on activation only when the // site needs it, kept in step with the connection, removed again on deactivation and uninstall. require_once HATCH_PLUGIN_DIR . 'includes/class-mu-plugin.php'; register_activation_hook( __FILE__, array( 'Hatch_Mu_Plugin', 'on_activate' ) ); register_deactivation_hook( __FILE__, array( 'Hatch_Mu_Plugin', 'on_deactivate' ) ); Hatch_Mu_Plugin::boot(); // Periodic HEAD probe of the site's own frontend URL so the Status panel can show liveness. require_once HATCH_PLUGIN_DIR . 'includes/class-cloud-heartbeat.php'; // V0.23 - design.md loader (brand tokens flow to the frontend as CSS variables). require_once HATCH_PLUGIN_DIR . 'includes/class-design-loader.php'; // V0.25 - Turnstile on wp-login + classic comment form (WP-side anti-spam). require_once HATCH_PLUGIN_DIR . 'includes/class-turnstile-wp.php'; // V0.6 - features. require_once HATCH_PLUGIN_DIR . 'includes/class-features.php'; // V0.7.5 - supported-blocks whitelist gate (opt-in toggle). require_once HATCH_PLUGIN_DIR . 'includes/class-blocks.php'; // V0.7 - real connection verification (heartbeat + webhook ack). require_once HATCH_PLUGIN_DIR . 'includes/class-connection-status.php'; // V0.7 - Block-to-Astro serializer (renders Gutenberg as native Astro components). require_once HATCH_PLUGIN_DIR . 'includes/class-block-serializer.php'; // In-plugin Cloudflare Workers deploy: provider interface, API client, // bundled frontend, edge proxy generator, provider and REST/admin-post controller. require_once HATCH_PLUGIN_DIR . 'includes/deploy/interface-hatch-deploy-provider.php'; require_once HATCH_PLUGIN_DIR . 'includes/deploy/class-hatch-cloudflare-api.php'; require_once HATCH_PLUGIN_DIR . 'includes/deploy/class-hatch-frontend-bundle.php'; require_once HATCH_PLUGIN_DIR . 'includes/deploy/class-hatch-cloudflare-proxy-worker.php'; require_once HATCH_PLUGIN_DIR . 'includes/deploy/class-hatch-cloudflare-provider.php'; require_once HATCH_PLUGIN_DIR . 'includes/deploy/class-hatch-wp-credential.php'; add_action( 'wp_authenticate_application_password_errors', array( 'Hatch_Wp_Credential', 'refuse_if_promoted' ), 10, 2 ); require_once HATCH_PLUGIN_DIR . 'includes/deploy/class-hatch-deploy-controller.php'; // v0.5.8. Cloudflare Worker SEO rewriter. Reads hatch_cf_worker_state option // (written by Hatch_Deploy_Controller on a successful deploy) and rewrites // permalinks + sitemap + robots so search engines see the Worker-served // subfolder as canonical. No-op when the option is empty. require_once HATCH_PLUGIN_DIR . 'includes/class-cf-seo.php'; add_action( 'init', array( 'Hatch_Cf_Seo', 'boot' ), 20 ); // v0.7.6 - guest-safe order lookup so /order-summary works without Cart-Token. require_once HATCH_PLUGIN_DIR . 'includes/class-order-lookup.php'; add_action( 'init', array( 'Hatch_Order_Lookup', 'boot' ), 5 ); // V0.8 - WooCommerce read-only bridge (products / variations / categories). require_once HATCH_PLUGIN_DIR . 'includes/class-woocommerce-bridge.php'; // Boot module loader after all core includes - picks up any feature-gated classes // that weren't loaded above (e.g. newly registered optional modules). Hatch_Module_Loader::boot(); require_once HATCH_PLUGIN_DIR . 'includes/class-hatch.php'; // Bootstrap. Hatch::instance();