' . esc_html__( 'The Hatch admin screen files are missing. Reinstall the plugin, or run the build step if you are working from the source repository.', 'hatch-connect' ) . '
'; } ); return; } $asset = file_exists( $asset_php ) ? require $asset_php : array( 'dependencies' => array( 'wp-element' ), 'version' => HATCH_VERSION, ); // Append the bundle mtime so the browser drops any stale cached copy. $bundle_version = (string) ( $asset['version'] ?? HATCH_VERSION ) . '.' . (string) filemtime( $bundle_js ); wp_enqueue_script( 'hatch-admin-react', HATCH_PLUGIN_URL . 'build/admin/index.jsx.js', (array) ( $asset['dependencies'] ?? array() ), $bundle_version, true ); if ( file_exists( $bundle_css ) ) { wp_enqueue_style( 'hatch-admin-react', HATCH_PLUGIN_URL . 'build/admin/index.jsx.css', array(), $bundle_version ); wp_style_add_data( 'hatch-admin-react', 'rtl', 'replace' ); } wp_set_script_translations( 'hatch-admin-react', 'hatch-connect', HATCH_PLUGIN_DIR . 'languages' ); // SSR-style boot state. The React app reads window.hatchBoot on first paint // and skips any initial fetch round-trip. wp_add_inline_script( 'hatch-admin-react', 'window.hatchBoot = ' . wp_json_encode( hatch_react_boot_state() ) . ';', 'before' ); } /** * Assemble the initial state payload for the React admin. Every option, * heartbeat, feature flag, and design token the SPA needs to render its first * paint without a fetch. Saves go through POST /hatch/v1/options. * * @return array */ function hatch_react_boot_state(): array { $hosting_model = (string) get_option( 'hatch_hosting_model', '' ); $frontend_url = trim( (string) get_option( 'hatch_frontend_url', '' ) ); $heartbeat_record = null; $heartbeat_health = 'muted'; $heartbeat_label = __( 'No check yet. The first one runs within a few minutes of a deploy.', 'hatch-connect' ); if ( class_exists( 'Hatch_Cloud_Heartbeat' ) ) { $host_for_hb = Hatch_Cloud_Heartbeat::provider_for_model( $hosting_model ); $heartbeat_record = Hatch_Cloud_Heartbeat::get( $host_for_hb ); $heartbeat_health = Hatch_Cloud_Heartbeat::health( $heartbeat_record ); // One method builds the sentence and the colour, so a 503 never reads "Responded in 210 ms". $heartbeat_label = Hatch_Cloud_Heartbeat::label( $heartbeat_record ); } // Preflight check list. Hatch_Diagnostic::run() returns an array of // { id, title, message, severity, fix } per check; we map to the React // shape { label, ok, warn, note }. Pass-through stays light, warns get a // fix hint, and fails surface the failure message. $preflight = array(); if ( class_exists( 'Hatch_Diagnostic' ) ) { $raw = (array) Hatch_Diagnostic::run(); $rows = isset( $raw['checks'] ) ? (array) $raw['checks'] : $raw; foreach ( $rows as $c ) { if ( ! is_array( $c ) ) { continue; } $sev = isset( $c['severity'] ) ? (string) $c['severity'] : 'pass'; $preflight[] = array( 'label' => isset( $c['title'] ) ? (string) $c['title'] : '', 'ok' => 'pass' === $sev, 'warn' => 'warn' === $sev, 'note' => isset( $c['message'] ) ? (string) $c['message'] : '', ); } } // Which admin app is mounting. Dashboard or setup wizard. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing between two screens, no state change. $page = ( isset( $_GET['page'] ) && 'hatch-setup' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) ? 'setup' : 'dashboard'; $home_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); return array( 'nonce' => wp_create_nonce( 'wp_rest' ), 'restUrl' => esc_url_raw( rest_url( 'hatch/v1/' ) ), 'adminUrl' => admin_url( 'admin.php?page=hatch' ), 'setupUrl' => admin_url( 'admin.php?page=hatch-setup' ), 'adminPostUrl' => admin_url( 'admin-post.php' ), 'pluginUrl' => plugins_url( '/', HATCH_PLUGIN_FILE ), 'brandLogoUrl' => plugins_url( 'assets/brand/hatch-lockup-horizontal.svg', HATCH_PLUGIN_FILE ), 'version' => HATCH_VERSION, 'page' => $page, 'siteHost' => $home_host, 'siteName' => get_bloginfo( 'name' ), 'state' => array( 'connection' => array( 'frontendUrl' => $frontend_url, 'hostLabel' => hatch_host_label( $hosting_model ), 'hostModel' => $hosting_model, // v0.5.9: actual mount mode chosen in the wizard so React // can render "Cloudflare Workers (root)" vs "(subfolder)" // instead of the hard-coded label it used to ship. 'mountMode' => (string) get_option( 'hatch_mount_mode', 'subfolder' ), 'mountSubpath' => (string) get_option( 'hatch_mount_subpath', '/blog' ), 'heartbeat' => array( 'healthClass' => $heartbeat_health, 'healthLabel' => $heartbeat_label, ), 'preflight' => $preflight, ), 'themes' => class_exists( 'Hatch_Features' ) ? array_map( static function ( $id, $row ) { return array( 'id' => $id, 'label' => $row['label'], 'desc' => $row['description'], 'icon' => $row['icon'], ); }, array_keys( Hatch_Features::themes() ), Hatch_Features::themes() ) : array(), // v0.50.13: wp_parse_args defaults so partial saves don't strip sibling // keys. Earlier shape returned only what was saved (e.g. {primary}) // which made the React UI render only one color picker. Defaults // ALWAYS merge in now; user-saved keys win. 'design' => array( 'theme' => class_exists( 'Hatch_Features' ) ? (string) Hatch_Features::get_theme() : '', // Defaults come from the design loader, so the pickers show the // colours the frontend actually renders before anything is saved. '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() ), // v0.50.14: canonical lowercase IDs. The React UI writes these // directly via setSetting() and the Astro frontend reads them // verbatim. Pretty display labels live in the JSX. 'layout' => wp_parse_args( (array) get_option( 'hatch_design_layout', array() ), array( 'density' => 'comfortable', 'rounded' => 'smooth', 'max_width' => '1160', 'button_style' => 'pill', ) ), 'font_heading' => (string) get_option( 'hatch_design_font_heading', 'Inter' ), 'font_body' => (string) get_option( 'hatch_design_font_body', 'Inter' ), 'font_mono' => (string) get_option( 'hatch_design_font_mono', 'JetBrains Mono' ), ), 'voice' => wp_parse_args( (array) get_option( 'hatch_design_voice', array() ), array( 'tone' => 'professional', 'pronouns' => 'we', ) ), 'identity' => wp_parse_args( (array) get_option( 'hatch_design_identity', array() ), array( 'logo_url' => '', 'favicon_url' => '', 'og_image_url' => '', 'site_title' => get_bloginfo( 'name' ), 'tagline' => get_bloginfo( 'description' ), ) ), 'templates' => wp_parse_args( (array) get_option( 'hatch_design_templates', array() ), array( 'single_sidebar' => 'right', 'single_hero' => 'featured', 'single_width' => 'medium', 'archive_grid' => '3', 'archive_excerpt' => true, 'not_found_search' => true, ) ), 'borders' => wp_parse_args( (array) get_option( 'hatch_design_borders', array() ), array( 'color' => '#e5e5e5', 'shadow' => 'soft', ) ), 'breakpoints' => (array) get_option( 'hatch_design_breakpoints', array( 'mobile' => 640, 'tablet' => 1024, 'desktop' => 1280, ) ), 'show_credit' => (bool) get_option( 'hatch_show_credit', true ), // v0.50.16: raw design.md source for the upload/paste textarea // in the Global card. Round-trips through Hatch_Design_Loader. 'design_md' => class_exists( 'Hatch_Design_Loader' ) ? Hatch_Design_Loader::get_raw() : '', // v0.50.15: Aesthetic surface for the Astro frontend. Seven option // groups, each its own wp_options row so a partial save can't drop // sibling keys. Defaults match the current hardcoded Astro behaviour // so existing installs see zero visual change on upgrade. 'share' => wp_parse_args( (array) get_option( 'hatch_design_share', array() ), array( 'x' => true, 'linkedin' => true, 'whatsapp' => true, 'copy' => true, 'facebook' => false, 'reddit' => false, 'email' => false, 'position' => 'inline', // inline | sticky | both ) ), 'header' => wp_parse_args( (array) get_option( 'hatch_design_header', array() ), array( 'sticky' => 'sticky', // sticky | static | hide_on_scroll 'blur' => true, 'brand_mark' => 'icon_text', // icon_text | text | initial 'brand_display' => 'auto', // auto | logo | text | both ) ), 'reading' => wp_parse_args( (array) get_option( 'hatch_design_reading', array() ), array( 'date_format' => 'long', // long | short | relative 'reading_time_label' => 'min_read', // min_read | mins | hidden 'breadcrumb_separator' => 'slash', // slash | chevron | arrow 'toc_depth' => 'h2_h3', // h2 | h2_h3 | h2_h3_h4 'toc_label' => 'On this page', 'author_avatar_shape' => 'circle', // circle | square | rounded 'progress_bar_position' => 'top', // top | bottom 'progress_bar_color' => 'primary', // primary | accent 'heading_anchors' => false, ) ), 'images' => wp_parse_args( (array) get_option( 'hatch_design_images', array() ), array( 'lightbox' => true, 'lazy_load' => true, 'hover_zoom' => true, 'fallback_gradient' => true, 'retina_2x' => true, 'aspect_ratio' => '2_1', // 2_1 | 3_1 | 16_9 ) ), 'animation' => wp_parse_args( (array) get_option( 'hatch_design_animation', array() ), array( 'page_transitions' => true, 'respect_reduced_motion' => true, ) ), 'blog_index' => wp_parse_args( (array) get_option( 'hatch_design_blog_index', array() ), array( 'archive_grid' => '3', // Columns, 1 to 4. 'pagination_style' => 'load_more', // load_more | numbered | infinite 'show_hero' => true, 'show_topics' => true, ) ), 'post_navigation' => wp_parse_args( (array) get_option( 'hatch_design_post_navigation', array() ), array( 'related_count' => 3, 'related_source' => 'category', // category | tags | mixed ) ), 'setup' => hatch_react_setup_state(), 'features' => class_exists( 'Hatch_Features' ) ? (array) Hatch_Features::get_all() : array(), 'featureCatalog' => class_exists( 'Hatch_Features' ) ? array_map( static function ( $slug, $info ) { return array( 'slug' => $slug, 'label' => (string) $info['label'], 'description' => (string) $info['description'], 'group' => (string) $info['group'], ); }, array_keys( Hatch_Features::catalog() ), Hatch_Features::catalog() ) : array(), 'featureGroups' => class_exists( 'Hatch_Features' ) ? array_map( static function ( $slug, $label ) { return array( 'slug' => $slug, 'label' => (string) $label, ); }, array_keys( (array) Hatch_Features::group_labels() ), (array) Hatch_Features::group_labels() ) : array(), 'snippets' => (array) get_option( 'hatch_code_snippets', array() ), // v0.50.14: content_flags slimmed to just Comments. Forms / // sitemap / RSS / robots / redirects all routed through their // respective WP plugins (Plugin Bridge auto-detects); having // Hatch-side toggles for them was duplicate config + dead UX. 'content' => wp_parse_args( (array) get_option( 'hatch_content_flags', array() ), array( 'comments_enabled' => true, 'comments_turnstile' => false, 'accounts_enabled' => false, ) ), // v0.50.13: read Turnstile from the authoritative source // (`hatch_integrations`). The earlier `hatch_turnstile` key was a // dispatcher artifact that no consumer read, so the UI showed // "Keys missing" even after the user typed them in. 'turnstile' => class_exists( 'Hatch_Integrations' ) ? (array) ( Hatch_Integrations::get_all()['turnstile'] ?? array() ) : array( 'enabled' => false, 'site_key' => '', 'secret_key' => '', ), 'menus' => hatch_react_menus_summary(), 'forms' => hatch_react_forms_summary(), 'pluginBridge' => hatch_react_plugin_bridge(), 'performance' => hatch_react_perf_state(), 'security' => hatch_react_security_state(), 'status' => hatch_react_status_snapshot(), // Plugins tab: the "supported blocks only" gate. 'blocks' => array( // v0.7.5: Plugin Bridge master toggle. Narrows inserter to // the 36 core blocks Hatch styles end-to-end when true. 'disable_unsupported' => (bool) get_option( 'hatch_blocks_disable_unsupported', 0 ), 'supported_count' => class_exists( 'Hatch_Blocks' ) ? Hatch_Blocks::count() : 36, 'supported_list' => class_exists( 'Hatch_Blocks' ) ? Hatch_Blocks::whitelist() : array(), ), // v0.50.31: WordPress Core Sync card. Surfaces every WP-owned // setting that affects headless rendering so users have ONE // status view + deep-links to the canonical WP UI. Read-only; // we don't duplicate WP's own admin pages, just show drift. 'coreSync' => hatch_react_core_sync(), ), ); } /** * v0.50.31: WordPress Core Sync snapshot. * * Hatch's job is to mirror what WordPress already owns. This payload gives * the Content tab a single status view of every WP-owned setting that * affects the headless frontend, with deep-links to the canonical WP UI. * READ-ONLY. We don't duplicate WP admin pages, we surface drift. * * Shape: * permalink: { structure, pretty, admin_url } * homepage: { mode (posts|page), static_id, static_title, admin_url } * menus: [{ loc, label, assigned, count, admin_url }] * post_types: [{ slug, label, count, public, in_rest, in_nav, admin_url }] * taxonomies: [{ slug, label, count, admin_url }] * languages: [{ code, label, default }] // populated if Polylang/WPML detected */ function hatch_react_core_sync(): array { // SITE IDENTITY, General Settings + Customizer. $logo_id = (int) get_theme_mod( 'custom_logo', 0 ); $site = array( 'title' => (string) get_bloginfo( 'name' ), 'tagline' => (string) get_bloginfo( 'description' ), 'url' => (string) home_url(), 'language' => (string) get_bloginfo( 'language' ), 'timezone' => '' !== (string) get_option( 'timezone_string' ) ? (string) get_option( 'timezone_string' ) : (string) get_option( 'gmt_offset' ), 'date_format' => (string) get_option( 'date_format' ), 'time_format' => (string) get_option( 'time_format' ), 'logo_url' => $logo_id ? (string) wp_get_attachment_image_url( $logo_id, 'full' ) : '', 'favicon_url' => function_exists( 'get_site_icon_url' ) ? (string) get_site_icon_url() : '', 'admin_url' => admin_url( 'options-general.php' ), 'customizer_url' => admin_url( 'customize.php?autofocus[section]=title_tagline' ), ); // PERMALINKS, frontend routing requires pretty perms. // v0.50.31: emit a HUMAN-READABLE example URL by substituting WP's // permalink tags with realistic placeholder values. `/blog/%postname%/` // becomes `/blog/your-post-title/`, instantly clear to non-devs. $structure = (string) get_option( 'permalink_structure', '' ); $example = strtr( $structure, array( '%postname%' => 'your-post-title', '%category%' => 'category', '%author%' => 'author', '%post_id%' => '123', '%year%' => gmdate( 'Y' ), '%monthnum%' => gmdate( 'm' ), '%day%' => gmdate( 'd' ), '%hour%' => gmdate( 'H' ), '%minute%' => gmdate( 'i' ), '%second%' => gmdate( 's' ), ) ); $permalink = array( 'structure' => $structure, 'example' => '' !== $example ? $example : '/?p=123', 'pretty' => '' !== $structure, 'admin_url' => admin_url( 'options-permalink.php' ), ); // HOMEPAGE, posts page vs static page. $show_on_front = (string) get_option( 'show_on_front', 'posts' ); $page_on_front = (int) get_option( 'page_on_front', 0 ); $homepage = array( 'mode' => $show_on_front, 'static_id' => $page_on_front, 'static_title' => $page_on_front ? (string) get_the_title( $page_on_front ) : '', 'admin_url' => admin_url( 'options-reading.php' ), ); // MENUS, every registered location + assignment state + item count // + the FULL menu list so the React admin can render an inline picker. $menu_locations = (array) get_registered_nav_menus(); $menu_assigned = (array) get_nav_menu_locations(); $all_menus_raw = wp_get_nav_menus(); $all_menus = array(); foreach ( (array) $all_menus_raw as $m ) { $all_menus[] = array( 'id' => (int) $m->term_id, 'name' => (string) $m->name, 'count' => (int) $m->count, ); } $menus = array(); foreach ( $menu_locations as $loc => $label ) { $mid = (int) ( $menu_assigned[ $loc ] ?? 0 ); $menu = $mid ? wp_get_nav_menu_object( $mid ) : null; $items = $mid ? (array) wp_get_nav_menu_items( $mid ) : array(); $menus[] = array( 'loc' => (string) $loc, 'label' => (string) $label, 'assigned_id' => $mid, 'assigned' => $menu ? (string) $menu->name : '', 'count' => count( $items ), 'admin_url' => admin_url( 'nav-menus.php?action=locations' ), ); } // DISCUSSION (Settings → Discussion), what WP says about comments. $discussion = array( 'default_comment_status' => (string) get_option( 'default_comment_status', 'open' ), 'comment_registration' => (bool) get_option( 'comment_registration', false ), 'comment_moderation' => (bool) get_option( 'comment_moderation', false ), 'require_name_email' => (bool) get_option( 'require_name_email', true ), 'admin_url' => admin_url( 'options-discussion.php' ), 'pending_count' => (int) ( wp_count_comments()->moderated ?? 0 ), 'approved_count' => (int) ( wp_count_comments()->approved ?? 0 ), ); // READING (Settings → Reading) $reading = array( 'posts_per_page' => (int) get_option( 'posts_per_page', 10 ), 'rss_use_excerpt' => (bool) get_option( 'rss_use_excerpt', false ), 'blog_public' => (bool) get_option( 'blog_public', true ), 'admin_url' => admin_url( 'options-reading.php' ), ); // PRIVACY (Settings → Privacy) $privacy_id = (int) get_option( 'wp_page_for_privacy_policy', 0 ); if ( $privacy_id && ! get_post( $privacy_id ) ) { // The saved page was deleted, so treat the setting as empty. $privacy_id = 0; } $privacy = array( 'page_id' => $privacy_id, 'page_title' => $privacy_id ? (string) get_the_title( $privacy_id ) : '', 'admin_url' => admin_url( 'options-privacy.php' ), ); // USERS / ROLES, useful for memberships + author archives. $role_counts = count_users(); $roles = array(); foreach ( (array) wp_roles()->role_names as $role => $name ) { $roles[] = array( 'slug' => (string) $role, 'name' => (string) $name, 'count' => (int) ( $role_counts['avail_roles'][ $role ] ?? 0 ), ); } // v0.50.31: AUTHORS, users who have published at least one post. // These become author archive pages on the Astro frontend (/blog/author/';
printf(
wp_kses(
/* translators: %s: link to the Settings, Permalinks screen. */
__( 'Hatch changed your permalinks to /%%postname%%/, because the frontend needs them. You can change this in Settings, Permalinks.', 'hatch-connect' ),
array(
'code' => array(),
'a' => array( 'href' => true ),
)
),
esc_url( admin_url( 'options-permalink.php' ) )
);
echo '
'; esc_html_e( 'Hatch cannot be network-activated. Each site has its own frontend address, saved token and theme, and sharing them across a network would mix them up. Activate it on individual sites instead.', 'hatch-connect' ); echo '
'; echo esc_html( sprintf( /* translators: %d: numeric ID of the current site in a multisite network. */ __( 'You are setting up Hatch for site ID %d. Its settings, saved token and frontend address apply to this site only. Other sites in the network are not affected.', 'hatch-connect' ), get_current_blog_id() ) ); echo '
'; printf( wp_kses( /* translators: %s: link to the Settings, Permalinks screen. */ __( 'Your permalinks (the link format of your posts) are set to Plain, and the frontend cannot read those. Choose Post name in Settings, Permalinks, or tick the permalinks box when you deploy and Hatch will change it for you.', 'hatch-connect' ), array( 'em' => array(), 'a' => array( 'href' => true ), ) ), esc_url( admin_url( 'options-permalink.php' ) ) ); echo '
'; echo esc_html( sprintf( /* translators: %s: comma-separated names of block plugins, for example "Kadence Blocks, Spectra". */ __( '%s is active. Blocks from these plugins depend on CSS that the frontend does not load, so they will look unstyled there. Core WordPress blocks and Hatch blocks display as designed.', 'hatch-connect' ), implode( ', ', $active ) ) ); echo '