.mo into the 'default' domain. */ const CONTAI_CORE_ADMIN_TEXTDOMAIN = 'contai-core-admin'; /** * Static mapping of sidebar IDs per theme. * * Used in cron/async context where $wp_registered_sidebars may be empty * because the theme's widgets_init hook has not fired. * * Every ID below was verified against the theme's own register_sidebar() call * (#48). Registering a widget against an ID the theme does not declare is a * silent no-op — no error, no log, no sidebar — which is the same failure mode * as the theme-mod keys corrected in v2.38.9. */ define( 'CONTAI_THEME_SIDEBAR_MAP', array( 'astra' => 'sidebar-1', 'generatepress' => 'sidebar-1', // Neve declares no 'sidebar-1' anywhere; its blog sidebar is 'blog-sidebar' // (neve 4.2.8: sidebar.php:8,14 is_active_sidebar('blog-sidebar')). 'neve' => 'blog-sidebar', 'blocksy' => 'sidebar-1', // Kadence registers 'sidebar-primary'/'sidebar-secondary', never 'sidebar-1' // (kadence 1.5.1: inc/components/layout/component.php:78-79). 'kadence' => 'sidebar-primary', 'sydney' => 'sidebar-1', 'oceanwp' => 'sidebar', 'newsmatic' => 'sidebar-1', // ColorMag prefixes every widget area; 'sidebar-right' does not appear in // the theme at all (colormag 4.2.1: // inc/widgets/class-colormag-widgets.php:27). 'colormag' => 'colormag_right_sidebar', ) ); /** * Static mapping of primary nav menu locations per theme. * * Used in cron/async context where get_registered_nav_menus() may return empty. * * Every location below was verified against the theme's own register_nav_menus() * call (#48). WordPress silently drops nav_menu_locations entries for locations * the active theme never registered, and a theme with no menu in its primary * location falls back to wp_page_menu() — which lists the published PAGES, i.e. * the generated legal pages. That is the original symptom this issue reported. */ define( 'CONTAI_THEME_NAV_LOCATION_MAP', array( 'astra' => 'primary', 'generatepress' => 'primary', 'neve' => 'primary', // Blocksy registers footer/menu_1/menu_2/menu_mobile; 'header-menu-1' is // not among them (blocksy 2.1.49: inc/init.php:409-412), so the wizard was // never assigning a menu at all and every Blocksy site fell through to the // wp_page_menu() page listing. 'blocksy' => 'menu_1', 'kadence' => 'primary', 'sydney' => 'primary', 'oceanwp' => 'main_menu', // 'menu-1' is registered, but it is Newsmatic's thin TOP bar // (newsmatic 1.5.0: inc/hooks/header-hooks.php:347). The main header nav // reads 'menu-2' (header-hooks.php:186), which is also what every one of // the theme's own demo imports assigns (inc/admin/assets/demos.php). 'newsmatic' => 'menu-2', 'colormag' => 'primary', ) ); /** * Get the primary sidebar ID for the active theme. * * Uses a static mapping to reliably resolve the sidebar ID even in * cron/async context where $wp_registered_sidebars may be empty. * * @return string The primary sidebar ID */ function contai_get_primary_sidebar_id(): string { $theme = get_option( 'contai_wordpress_theme', 'astra' ); if ( isset( CONTAI_THEME_SIDEBAR_MAP[ $theme ] ) ) { return CONTAI_THEME_SIDEBAR_MAP[ $theme ]; } // Runtime detection, but only from a registry that describes the ACTIVE // theme. The wizard reaches this in the same request as switch_theme(), where // $wp_registered_sidebars still lists the outgoing theme's widget areas, so // reading an id out of it hands back a sidebar the active theme does not // render — a silent no-op, exactly like the nav locations (#48). global $wp_registered_sidebars; $resolved = contai_sidebar_id_from_registry( $wp_registered_sidebars, contai_nav_registry_is_stale() ); if ( null !== $resolved ) { return $resolved; } // Nothing trustworthy to resolve from: an unmapped theme whose widget areas // we cannot see. 'sidebar-1' is the WordPress convention and stays the // last-resort guess, but it IS a guess, so it leaves a durable trace instead // of failing invisibly the way this whole class of bug did. contai_record_site_warning( 'sidebar id', sprintf( "theme '%s' is not in CONTAI_THEME_SIDEBAR_MAP and the widget-area registry could not be read%s; falling back to 'sidebar-1', which the theme may not register", $theme, contai_nav_registry_is_stale() ? ' (it still described the previous theme)' : '' ) ); return 'sidebar-1'; } /** * Get the primary nav menu location for the active theme. * * Uses a static mapping to reliably resolve the menu location even in * cron/async context where get_registered_nav_menus() may return empty. * * @return string|null The primary nav menu location or null if unknown */ function contai_get_primary_nav_location(): ?string { $theme = get_option( 'contai_wordpress_theme', 'astra' ); if ( isset( CONTAI_THEME_NAV_LOCATION_MAP[ $theme ] ) ) { return CONTAI_THEME_NAV_LOCATION_MAP[ $theme ]; } return null; } /** * Apply theme-specific default settings after installation. * * Each theme may need different reading settings, sidebar layouts, * or other configuration to look good out of the box with generated content. * * @param string $theme Theme slug * @return void */ function contai_apply_theme_defaults( string $theme ): void { // Common defaults for all themes update_option( 'show_on_front', 'posts' ); update_option( 'posts_per_page', 15 ); switch ( $theme ) { case 'newsmatic': // A guarded call to contai_set_newsmatic_reading_defaults() stood // here from the initial commit. That function has never been // defined anywhere in this plugin's history // (`git log --all -S "function contai_set_newsmatic_reading_defaults"` // returns nothing), so function_exists() was always false and the // branch never ran — a dead call that read as deliberate coverage // of Newsmatic's reading settings (#48). The reading defaults that // actually apply are the show_on_front / posts_per_page writes // shared by every theme above. // // 'newsmatic_breadcrumb_option' does not exist in the theme (#48): // the only matches for that string are the customizer SECTION id // 'newsmatic_breadcrumb_options_section'. The setting Newsmatic // actually reads is 'site_breadcrumb_option' // (inc/extras/helpers.php:102 via newsmatic_get_customizer_option(), // which is get_theme_mod() with a default from // inc/theme-starter.php:118). It is a boolean and already defaults // to true, so this write matters for keeping breadcrumbs on when a // previous value turned them off. set_theme_mod( 'site_breadcrumb_option', true ); break; case 'oceanwp': // 'ocean_blog_layout' does not exist in the theme (#48). The blog // and archive layout is 'ocean_blog_archives_layout' // (inc/helpers.php:505, inside oceanwp_post_layout()). The value // vocabulary is unchanged: 'right-sidebar' is valid here. set_theme_mod( 'ocean_blog_archives_layout', 'right-sidebar' ); // Correct as-is: read at inc/helpers.php:2375 in // oceanwp_has_breadcrumbs(). Defaults to true already. set_theme_mod( 'ocean_breadcrumbs', true ); break; case 'generatepress': // Two independent defects here (#48). GeneratePress never reads // theme mods for its settings — everything goes through // generate_get_option(), backed by the 'generate_settings' option // (inc/theme-functions.php:20-33) — so set_theme_mod() was a silent // no-op. And 'content_layout_setting' is not a sidebar setting at // all: it selects container styling, and only accepts // 'separate-containers' / 'one-container' (inc/customizer.php: // 1055-1058), so 'content-sidebar' was never a valid value either. // // The sidebar layout keys are these. They already default to // 'right-sidebar' (inc/defaults.php:46-48), so this write exists to // assert the wizard's intent on a site that was previously // configured otherwise, not to change a fresh install. contai_generatepress_apply_settings( array( 'layout_setting' => 'right-sidebar', 'blog_layout_setting' => 'right-sidebar', 'single_layout_setting' => 'right-sidebar', ) ); // No breadcrumbs setting is written: GeneratePress 3.6.1 has no // breadcrumbs feature at all (the single 'breadcrumb' match in the // theme is a bbPress CSS selector in inc/plugin-compat.php:197). break; case 'colormag': // 'colormag_site_layout' is a pre-3.0 key that meant boxed-vs-wide // CONTAINER WIDTH, and today only survives inside a migration // teardown (inc/migration/class-colormag-migration.php:719-729). // Writing 'right-sidebar' there was worse than inert: it makes the // migration's guard truthy while matching neither branch, leaving // $new_container_layout undefined and corrupting // 'colormag_container_layout' if that migration ever runs. // // The live keys are these, and ColorMag's value vocabulary uses // UNDERSCORES ('right_sidebar'), not hyphens // (inc/customizer/options/content/blog.php:31, // inc/customizer/options/global/layout.php). Both are needed: the // blog reader's runtime default is 'no_sidebar', not 'default', so // it never falls through to the global value on its own // (inc/template-functions.php:204-206). set_theme_mod( 'colormag_blog_sidebar_layout', 'right_sidebar' ); set_theme_mod( 'colormag_global_sidebar_layout', 'right_sidebar' ); // 'colormag_breadcrumb_display' does not exist in the theme. The // real gate is 'colormag_breadcrumb_enable', compared loosely // against 1 and defaulting to 0 = OFF // (template-parts/hooks/header/header.php:526), so unlike the other // themes here this write is what actually turns breadcrumbs on. set_theme_mod( 'colormag_breadcrumb_enable', 1 ); break; case 'astra': // Astra reads every one of its settings from the astra-settings // option via astra_get_option(), never from theme mods, so these // MUST NOT go through set_theme_mod() (#48). // // 'breadcrumb-position' doubles as the breadcrumbs on/off switch: // it defaults to 'none' (= hidden) and 'astra_entry_top' renders // them before the title. The separator is intentionally left alone // — Astra already defaults it to '\00bb' (a CSS escape for », // injected into a content: rule), which is the character the // previous code was trying to set. Writing a raw » there would put // a literal glyph where a CSS escape sequence is expected. contai_astra_apply_settings( array( // Force right sidebar layout site-wide. 'site-sidebar-layout' => 'right-sidebar', 'single-post-sidebar-layout' => 'right-sidebar', 'archive-post-sidebar-layout' => 'right-sidebar', // Show breadcrumbs before the entry title. 'breadcrumb-position' => 'astra_entry_top', ) ); break; case 'neve': // Neve's "Advanced Options" (on by default) route the blog/category archive // layout through its own mod, independent of neve_default_sidebar_layout — and // it defaults to full-width. Without this, the archive/home listing (where the // sidebar widgets from contai_add_sidebar_widgets() are supposed to render) // silently drops the sidebar even though single posts show it correctly (#46). // 'right' is the correct value here — Neve's vocabulary is // left/right/full-width (inc/customizer/defaults/utils.php:23-46), // not the 'right-sidebar' other themes use. set_theme_mod( 'neve_blog_archive_sidebar_layout', 'right' ); set_theme_mod( 'neve_single_post_sidebar_layout', 'right' ); // Static pages have their own key and also default to full-width // (inc/customizer/defaults/layout.php:26-34), so without this they // silently stayed sidebar-less (#48). set_theme_mod( 'neve_other_pages_sidebar_layout', 'right' ); // Deliberately NOT set (#48): // // - 'neve_default_sidebar_layout' is only consulted when advanced // layout options are OFF (inc/views/layouts/layout_sidebar.php: // 110-128), and they default to ON, so it was inert. Worse, it is // a sentinel in Neve's new-user detection // (inc/core/migration_flags.php:99-100): writing it makes Neve // classify a brand-new site as a pre-v4 upgrade and silently swap // in legacy defaults for blog cards, typography and element order. // // - 'neve_breadcrumbs' is not a setting at all; the only occurrences // of that string are filter names. Neve free has no stored // breadcrumbs toggle: is_breadcrumb_enabled() // (inc/views/breadcrumbs.php:64-90) returns false unless Yoast, // SEOPress, Rank Math or Breadcrumb NavXT is active. There is // nothing a plugin can set here. break; case 'blocksy': // The key shape was right but the VALUE was not: Blocksy's // '{prefix}_has_sidebar' is a 'yes'/'no' switch, and position is a // separate mod (inc/sidebar.php:259-266). Writing 'right' happened // to enable the sidebar by passing the !== 'no' test, but it is not // a value the theme or its customizer recognises. set_theme_mod( 'blog_has_sidebar', 'yes' ); set_theme_mod( 'blog_sidebar_position', 'right' ); // 'single_has_sidebar' does not exist, and singular views never // reach the _has_sidebar branch at all — the single-post prefix is // 'single_blog_post' (inc/classes/screen-manager.php:342) and its // sidebar comes from a structure picker, where 'type-1' means right // sidebar (inc/sidebar.php:284-294, // inc/options/single-elements/structure.php:29-47). It defaults to // 'type-3' = no sidebar. set_theme_mod( 'single_blog_post_structure', 'type-1' ); // 'breadcrumb_visibility' does not exist either. Breadcrumbs are an // entry in the per-prefix hero elements list and ship disabled for // everything but WooCommerce products, so the whole list has to be // written back with that entry flipped. The hero itself also has to // be on, and it defaults to 'no' specifically for the blog prefix // (inc/components/hero-section.php:66-76). set_theme_mod( 'single_blog_post_hero_elements', contai_hero_elements_enable( get_theme_mod( 'single_blog_post_hero_elements' ), 'breadcrumbs', array( array( 'id' => 'custom_title', 'enabled' => true, ), array( 'id' => 'custom_description', 'enabled' => true, ), array( 'id' => 'custom_meta', 'enabled' => true, ), ) ) ); // The legal pages the wizard generates are the 'page' post type, // which Blocksy addresses under a SEPARATE prefix ('single_page', // not 'single_blog_post' -- inc/classes/screen-manager.php:159-165, // compute_post_type_for_prefix() maps it to post_type 'page'), with // its own hero-elements theme mod (#48: verified live -- running the // wizard's theme defaults for a real page rendered 0 breadcrumb // markers on 'blocksy' until this write was added). Its hero is on // by default (single_page is not the 'blog' prefix that defaults to // 'no' -- inc/dynamic-styles/page-title/page-title.php:9-13), so only // the element list needs writing. The fallback mirrors what Blocksy // itself would generate as defaults for this prefix // (inc/components/hero/elements.php:17-53): custom_meta defaults to // disabled here because it is gated by `! $is_page` there. set_theme_mod( 'single_page_hero_elements', contai_hero_elements_enable( get_theme_mod( 'single_page_hero_elements' ), 'breadcrumbs', array( array( 'id' => 'custom_title', 'enabled' => true, ), array( 'id' => 'custom_description', 'enabled' => true, ), array( 'id' => 'custom_meta', 'enabled' => false, ), ) ) ); set_theme_mod( 'blog_hero_enabled', 'yes' ); set_theme_mod( 'blog_hero_elements', contai_hero_elements_enable( get_theme_mod( 'blog_hero_elements' ), 'breadcrumbs', array( array( 'id' => 'custom_title', 'enabled' => true, ), array( 'id' => 'custom_description', 'enabled' => true, ), ) ) ); break; case 'kadence': // Correct as-is: read as kadence()->option( $post_type . '_layout' ) // (inc/components/layout/component.php:622), and 'right' is a valid // choice (inc/customizer/options/post-layout-options.php:671-691). set_theme_mod( 'post_layout', 'right' ); // The bare key 'archive_layout' is never read — every occurrence is // part of a longer key. The blog archive reads 'post_archive_layout' // (inc/components/layout/component.php:838-840, $archive_type is // 'post_archive'), which defaults to 'normal' (#48). set_theme_mod( 'post_archive_layout', 'right' ); // 'breadcrumb_enable' does not exist. Kadence breadcrumbs are a // title-area element toggled by an ARRAY sub-option read with // sub_option( $type . '_title_element_breadcrumb', 'enabled' ) // (inc/components/entry_title/component.php:60-63 and // inc/components/archive_title/component.php:114-117), and both // default to 'enabled' => false // (inc/components/options/component.php:2833, :2892). set_theme_mod( 'post_title_element_breadcrumb', array( 'enabled' => true, 'show_title' => true, ) ); // The legal pages the wizard generates are the 'page' post type, not // 'post' -- render_title() keys the sub_option lookup off $post_type // (inc/components/entry_title/component.php:60-63), so the write // above never reaches them. That page-title key is separate and also // defaults to disabled (inc/components/options/component.php:2650-2653) // (#48: verified live -- running the wizard's theme defaults for a real // page rendered 0 breadcrumb markers on 'kadence' until this write was // added, even though the post/post-archive writes above are correct). set_theme_mod( 'page_title_element_breadcrumb', array( 'enabled' => true, 'show_title' => true, ) ); set_theme_mod( 'post_archive_title_element_breadcrumb', array( 'enabled' => true, 'show_title' => true, ) ); break; case 'sydney': // The bare key 'sidebar_position' is never read: Sydney builds its // mod names at runtime as 'sidebar_single_{post_type}_position' // (inc/extras.php:437) and uses a separate key for archives // (inc/extras.php:427). The literal is only a function name, // sydney_sidebar_position(). The value vocabulary is unchanged. set_theme_mod( 'sidebar_archives_position', 'sidebar-right' ); set_theme_mod( 'sidebar_single_post_position', 'sidebar-right' ); set_theme_mod( 'sidebar_single_page_position', 'sidebar-right' ); // No breadcrumbs write (#48): 'enable_breadcrumbs' appears nowhere // in the theme, and Sydney free 2.69 has no general breadcrumbs // feature to enable — it is a PRO module // (inc/dashboard/class-dashboard-settings.php:430-439). The only // free breadcrumb output is an unconditional Yoast passthrough // (inc/extras.php:90-93) with no toggle. break; default: // $theme is API-supplied (SiteConfigService::…update_option // 'contai_wordpress_theme') and only ever passed through // sanitize_text_field(), which validates its characters, not its // membership in the theme maps. An unmapped slug reaching here gets // no theme configuration from the switch above AND no primary nav // location from contai_get_primary_nav_location(), which returns // null for a slug it does not know — leaving the theme to fall back // to wp_page_menu(), i.e. a menu of the generated legal pages, // which is the originally reported symptom (#48). // // Both outcomes were silent. Neither is repaired here: guessing a // location for an unknown theme is what the hand-verified maps // exist to avoid. It is only made visible. contai_record_site_warning( 'theme defaults', sprintf( "theme '%s' is not in the theme maps: no theme-specific configuration and no primary nav location will be applied. Mapped themes: %s", $theme, implode( ', ', array_keys( CONTAI_THEME_NAV_LOCATION_MAP ) ) ) ); break; } } /** * Install and activate a WordPress theme * * Downloads and installs a theme from WordPress.org theme repository if not already installed, * then activates it. * * @param string $theme Theme slug to install and activate * @return void */ function contai_install_theme( $theme ) { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; require_once ABSPATH . 'wp-admin/includes/theme.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; $installed = wp_get_theme( $theme )->exists(); if ( ! $installed ) { $api = themes_api( 'theme_information', array( 'slug' => $theme, 'fields' => array( 'sections' => false ), ) ); if ( is_wp_error( $api ) ) { contai_log( 'contai_install_theme: themes_api failed for "' . $theme . '": ' . $api->get_error_message() ); throw new Exception( 'Theme API lookup failed for "' . $theme . '": ' . $api->get_error_message() ); } $upgrader = new Theme_Upgrader( new WP_Ajax_Upgrader_Skin() ); $result = $upgrader->install( $api->download_link ); if ( is_wp_error( $result ) ) { contai_log( 'contai_install_theme: install failed for "' . $theme . '": ' . $result->get_error_message() ); throw new Exception( 'Theme install failed for "' . $theme . '": ' . $result->get_error_message() ); } if ( $result === false ) { contai_log( 'contai_install_theme: install returned false for "' . $theme . '"' ); throw new Exception( 'Theme install failed for "' . $theme . '"' ); } } switch_theme( $theme ); // Verify the theme was activated $active = wp_get_theme()->get_stylesheet(); if ( $active !== $theme ) { contai_log( 'contai_install_theme: switch_theme failed. Expected "' . $theme . '", got "' . $active . '"' ); throw new Exception( 'Theme activation failed: expected "' . $theme . '", active is "' . $active . '"' ); } } /** * Handle widget generation submission * * Generates default widgets for the sidebar including: * - About Me profile card with AI-generated bio * - Search widget * - Recent Comments widget * - Recent Posts widget * * Also applies Newsmatic theme defaults if applicable. * * @return void */ function contai_handle_generate_widget_submit() { contai_add_sidebar_widgets(); } /** * Generate site icon via WPContentAI image generation API. * * Creates a minimalist square icon and assigns it as the site icon (favicon). * * @return int|WP_Error Attachment ID on success, WP_Error on failure */ function contai_handle_generate_icon_submit() { $result = contai_generate_and_set_site_icon_from_openai(); if ( is_wp_error( $result ) ) { contai_log( 'contai_handle_generate_icon_submit: Error — ' . $result->get_error_message() ); throw new Exception( 'Icon generation failed: ' . $result->get_error_message() ); } } /** * Load core's admin translations for one locale into a private text domain. * * The slugs of the stock sample content are translated strings, and they live * in admin-.mo. load_default_textdomain() only loads that file when * is_admin() (wp-includes/l10n.php), and the wizard runs from the job queue -- * the same context that makes contai_flush_rewrite_rules_hard() load * wp-admin/includes/misc.php by hand. So _x() on its own returns the English * literal here, and a "fix" built on it would be one more silent no-op. * * Loading into a private domain rather than 'default' keeps the request's own * translations untouched. Core derives the .l10n.php path from the .mo path * (l10n.php, load_textdomain()), so passing the .mo covers both formats. * * @param string $locale Locale whose admin translations to load. * @return bool True when the file was loaded and translations are available. */ function contai_load_core_admin_translations( string $locale ): bool { if ( ! function_exists( 'load_textdomain' ) || ! defined( 'WP_LANG_DIR' ) ) { return false; } // en_US ships no translation file; the literals are already correct. if ( '' === $locale || 'en_US' === $locale ) { return false; } return (bool) load_textdomain( CONTAI_CORE_ADMIN_TEXTDOMAIN, WP_LANG_DIR . '/admin-' . $locale . '.mo', $locale ); } /** * Locales whose sample-content slugs are worth trying, request locale LAST. * * determine_locale() is the locale of THIS REQUEST, not of the install: under * is_admin() (or a _locale=user JSON request) it returns get_user_locale(), and * any plugin can filter it (wp-includes/l10n.php). Core, however, created the * sample content once, at install time, from the SITE locale. So a site * installed in Spanish whose language was later switched to English still has * Spanish slugs, and resolving only the request locale would leave the original * bug unfixed for exactly that population. Both are tried. * * The order matters: load_textdomain() calls set_locale() on the shared * translation controller (l10n.php), so finishing on the request's own locale * leaves the rest of the request reading translations as it did before. * * @return string[] Distinct locales, request locale last. */ function contai_core_locale_candidates(): array { $site = function_exists( 'get_locale' ) ? (string) get_locale() : ''; $request = function_exists( 'determine_locale' ) ? (string) determine_locale() : ''; $locales = array(); foreach ( array( $site, $request ) as $locale ) { if ( '' !== $locale && ! in_array( $locale, $locales, true ) ) { $locales[] = $locale; } } return $locales; } /** * What WordPress actually called the stock sample content on this install. * * Core creates it from translated strings -- sanitize_title( _x( 'hello-world', * 'Default post slug' ) ) / __( 'Hello world!' ) at * wp-admin/includes/upgrade.php:247-249, and __( 'sample-page' ) / * __( 'Sample Page' ) at :352-354 -- and looks the post back up the same way * (:529). On an es_ES install the slugs are 'hola-mundo' and 'pagina-ejemplo', * so the English literals matched nothing and the cleanup was a silent no-op on * every non-English site. This plugin's own default content language is Spanish * (contai_site_language), so that is the expected install, not an edge case. * * Each candidate carries its title as well, because the slug alone is not * enough to justify a destructive action: the title is what tells the stock * item apart from a page the site owner happens to have slugged the same way. * * @param string $post_type 'post' or 'page'. * @return array Candidates, no duplicate slugs. */ function contai_stock_sample_candidates( string $post_type ): array { $is_post = ( 'post' === $post_type ); $candidates = array(); $seen = array(); $add = static function ( $slug, $title ) use ( &$candidates, &$seen ) { $slug = (string) $slug; if ( '' === $slug || isset( $seen[ $slug ] ) ) { return; } $seen[ $slug ] = true; $candidates[] = array( 'slug' => $slug, 'title' => (string) $title, ); }; foreach ( contai_core_locale_candidates() as $locale ) { if ( ! contai_load_core_admin_translations( $locale ) ) { continue; } // phpcs:disable WordPress.WP.I18n.TextDomainMismatch $slug = $is_post ? _x( 'hello-world', 'Default post slug', CONTAI_CORE_ADMIN_TEXTDOMAIN ) : __( 'sample-page', CONTAI_CORE_ADMIN_TEXTDOMAIN ); $title = $is_post ? __( 'Hello world!', CONTAI_CORE_ADMIN_TEXTDOMAIN ) : __( 'Sample Page', CONTAI_CORE_ADMIN_TEXTDOMAIN ); // phpcs:enable WordPress.WP.I18n.TextDomainMismatch $add( $slug, $title ); // A translation is not guaranteed to be slug-safe; core stores the post // under sanitize_title() of it (upgrade.php:249). if ( function_exists( 'sanitize_title' ) ) { $add( sanitize_title( (string) $slug ), $title ); } } // Always a candidate: a site installed in English and later switched keeps // the English slugs. $add( $is_post ? 'hello-world' : 'sample-page', $is_post ? 'Hello world!' : 'Sample Page' ); return $candidates; } /** * Retire one stock sample item without ever destroying content. * * wp_trash_post() PERMANENTLY DELETES when EMPTY_TRASH_DAYS is 0 * (wp-includes/post.php:4006-4009), and that is a documented wp-config setting. * This code path matched nothing on non-English installs until now, so the * change that finally makes it effective must not also be the change that * destroys content on those sites. Where the trash is not a trash, the item is * demoted to a draft instead: unpublished is all the wizard needs -- it leaves * the blog index and leaves wp_page_menu(), whose get_pages() default is * 'post_status' => 'publish' (post.php:6428) -- and a draft is fully * recoverable. * * @param int $post_id Item to retire. * @param int|null $empty_trash_days Override for the constant, for tests. * @return bool True when the item was retired. */ function contai_retire_stock_sample( int $post_id, ?int $empty_trash_days = null ): bool { if ( null === $empty_trash_days ) { $empty_trash_days = defined( 'EMPTY_TRASH_DAYS' ) ? (int) EMPTY_TRASH_DAYS : 0; } if ( $empty_trash_days < 1 ) { $updated = wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft', ) ); return ! empty( $updated ) && ! is_wp_error( $updated ); } return (bool) wp_trash_post( $post_id ); } /** * Retire the stock sample post and page so they stop polluting the site. * * They matter to navigation specifically: the wizard forces the front page to * the blog index (contai_apply_theme_defaults()), where the sample post renders * among the generated content, and a published "Sample Page" is picked up by * the wp_page_menu() fallback -- the same listing that surfaces the legal pages * as the main navigation, which is the symptom this issue opened with. * * Nothing is destroyed and nothing happens silently: a slug match alone is not * acted on, because get_page_by_path() also matches attachments and widens to * one (post.php:6177-6222), and because an owner may legitimately have a page * at that slug. The title has to match the stock title too, and every outcome * -- retired, skipped, failed -- leaves a record. * * @return void */ function contai_delete_sample_content(): void { foreach ( array( 'post', 'page' ) as $post_type ) { foreach ( contai_stock_sample_candidates( $post_type ) as $candidate ) { $found = get_page_by_path( $candidate['slug'], OBJECT, $post_type ); // get_page_by_path() searches the requested type AND 'attachment', // and assigns the id before checking the type (post.php:6215-6222), // so an unattached media item at this slug can come back here. if ( ! $found || ! isset( $found->post_type ) || $found->post_type !== $post_type ) { continue; } if ( isset( $found->post_title ) && (string) $found->post_title !== $candidate['title'] ) { // The slug is core's, the content is not. Leave it alone -- // but say so, because a destructive step that quietly decides // not to act is the same invisibility this issue is about. contai_record_site_warning( 'sample content', sprintf( "left the %s at '%s' (ID %d) alone: title %s does not match the stock %s", $post_type, $candidate['slug'], (int) $found->ID, '"' . (string) $found->post_title . '"', '"' . $candidate['title'] . '"' ) ); break; } if ( contai_retire_stock_sample( (int) $found->ID ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( "[ContAI] retired the stock sample %s '%s' (ID %d)", $post_type, $candidate['slug'], (int) $found->ID ) ); } else { contai_record_site_warning( 'sample content', sprintf( "could not remove the stock sample %s '%s' (ID %d); it will keep showing on the site", $post_type, $candidate['slug'], (int) $found->ID ) ); } // One stock item per type; do not keep matching other candidates. break; } } } /** * Setup general site configuration settings * * Configures site-wide settings that are not theme-specific: * - Permalink structure set to post name * - Rewrite rules flush * - Reading defaults setup * - Sample content cleanup * * This function is called during website generation to prepare the site * for content creation and proper URL structure. * * @return void */ function contai_setup_site_config() { // Not update_option( 'permalink_structure', … ). $wp_rewrite caches the // structure in a property read once in WP_Rewrite::init(), called from the // constructor at request start (wp-settings.php), and update_option() does // not resync it — core has no 'update_option_permalink_structure' hook, and // every core writer goes through set_permalink_structure(), which pairs the // option write with $this->init(). Writing the option directly and then // flushing meant the flush below regenerated rules from the OLD structure // and persisted them, and a populated stale ruleset does not self-heal // (#48). It happened to be masked by the unrelated flush that // check_theme_switched() runs after switch_theme(); this no longer depends // on that accident. if ( isset( $GLOBALS['wp_rewrite'] ) && is_object( $GLOBALS['wp_rewrite'] ) && method_exists( $GLOBALS['wp_rewrite'], 'set_permalink_structure' ) ) { $GLOBALS['wp_rewrite']->set_permalink_structure( '/%postname%/' ); } else { update_option( 'permalink_structure', '/%postname%/' ); } update_option( 'contai_flush_rewrite', true ); // Actually flush. Writing the permalink_structure option does NOT // regenerate anything on its own, and the 'contai_flush_rewrite' flag above // was written for a consumer that does not exist: its only other occurrence // in the repo is uninstall.php's delete list, so nothing has ever read it // (#48). flush_rewrite_rules() likewise appeared nowhere in the plugin. // // What that costs, read from WordPress core rather than assumed: // WP_Rewrite::wp_rewrite_rules() (class-wp-rewrite.php:1493-1500) does // self-heal the 'rewrite_rules' OPTION, but only when it is empty — a // populated ruleset left over from the previous structure survives intact. // And in neither case is .htaccess touched: save_mod_rewrite_rules() is // reached only through a HARD flush_rules() (class-wp-rewrite.php:1899-1903). // On Apache, without the WordPress block in .htaccess every pretty permalink // 404s — the generated posts and the category archives the main menu links // to — which is the reported "generated site's navigation does not work", // arrived at silently. contai_flush_rewrite_rules_hard(); // Enable comments on new posts by default update_option( 'default_comment_status', 'open' ); contai_delete_sample_content(); } /** * Flush rewrite rules hard, including .htaccess, from any request context. * * save_mod_rewrite_rules() lives in wp-admin/includes/misc.php, and * WP_Rewrite::flush_rules() guards its call with function_exists(). The wizard * runs its steps through the job queue (wp-cron), where wp-admin is not loaded, * so a plain flush_rewrite_rules( true ) there would silently degrade to a soft * flush and leave .htaccess untouched — the same class of silent no-op this * fixes. Loading the file first is what makes the "hard" part real. * * @return void */ function contai_flush_rewrite_rules_hard(): void { if ( ! function_exists( 'save_mod_rewrite_rules' ) && defined( 'ABSPATH' ) ) { $misc = ABSPATH . 'wp-admin/includes/misc.php'; if ( file_exists( $misc ) ) { require_once $misc; } } if ( ! function_exists( 'flush_rewrite_rules' ) ) { return; } flush_rewrite_rules( true ); // The flag now describes reality instead of a permanent "todo". update_option( 'contai_flush_rewrite', false ); } /** * Configure site metadata including title and tagline * * Sets up core site metadata: * - Site Title: Set to the website host/domain name (without protocol) * - Tagline: AI-generated short description based on site topic and language * * This function extracts the host from the site URL and generates a compelling * tagline using OpenAI based on the configured site topic and language settings. * * @return void */ function contai_configure_site_metadata() { $site_url = home_url(); $host = wp_parse_url( $site_url, PHP_URL_HOST ); if ( $host ) { update_option( 'blogname', $host ); } // Fetch the AI-generated tagline from the API $website_provider = new ContaiWebsiteProvider(); $response = $website_provider->getWebsiteFromApi(); if ( $response && $response->isSuccess() ) { $data = $response->getData(); $tagline = $data['site_description'] ?? ''; if ( ! empty( $tagline ) ) { update_option( 'blogdescription', sanitize_text_field( $tagline ) ); } } } /** * Create a footer navigation menu containing legal pages. * * Finds all published pages created by the legal page generator * (identified by _contai_legal_source meta) and adds them to a * "Footer" menu assigned to the theme's footer menu location. * * @return void */ function contai_create_footer_menu_with_legal_pages(): void { // Shared with the wp_footer fallback, which finds the menu by this exact // name (footer-legal-fallback.php). A literal on each side would drift. $menu_name = CONTAI_FOOTER_MENU_NAME; $menu = wp_get_nav_menu_object( $menu_name ); if ( $menu ) { $menu_id = $menu->term_id; } else { $menu_id = wp_create_nav_menu( $menu_name ); if ( is_wp_error( $menu_id ) ) { // The caller appends 'Footer menu with legal pages created' as soon // as this returns without throwing, so a debug-gated contai_log() // here meant the wizard claimed a footer menu it had failed to // create — with nothing to read anywhere (#48). contai_record_site_warning( 'footer menu', 'failed to create the menu: ' . $menu_id->get_error_message() ); return; } } // Find legal pages by meta // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value $legal_pages = get_posts( array( 'post_type' => 'page', 'post_status' => 'publish', 'meta_key' => '_contai_legal_source', 'meta_value' => 'contai_api', 'numberposts' => 20, 'orderby' => 'title', 'order' => 'ASC', ) ); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value if ( empty( $legal_pages ) ) { // The only exit in this function that used to leave no trace: the step // still reports "Footer menu with legal pages created" while the menu // stays empty and unbound to any location (#48). contai_record_site_warning( 'footer legal menu', "no published pages carry _contai_legal_source = 'contai_api', so the footer menu was left empty and unassigned" ); return; } // Get existing menu items to avoid duplicates $existing_items = wp_get_nav_menu_items( $menu_id ); $existing_ids = array(); if ( $existing_items ) { foreach ( $existing_items as $item ) { if ( $item->type === 'post_type' && $item->object === 'page' ) { $existing_ids[] = (int) $item->object_id; } } } $position = count( $existing_ids ) + 1; foreach ( $legal_pages as $page ) { if ( in_array( $page->ID, $existing_ids, true ) ) { continue; } $item_id = wp_update_nav_menu_item( $menu_id, 0, array( 'menu-item-title' => $page->post_title, 'menu-item-object' => 'page', 'menu-item-object-id' => $page->ID, 'menu-item-type' => 'post_type', 'menu-item-status' => 'publish', 'menu-item-position' => $position, ) ); // Core inserts the item post BEFORE attaching it to the menu term and // returns the WP_Error from that attach (wp-includes/nav-menu.php), so // a failure here leaves a published nav_menu_item bound to no menu: // it renders nowhere and, until now, said nothing. if ( is_wp_error( $item_id ) || ! $item_id ) { contai_record_site_warning( 'footer legal menu', sprintf( "could not add '%s' (page %d) to the footer menu: %s", $page->post_title, (int) $page->ID, is_wp_error( $item_id ) ? $item_id->get_error_message() : 'no item id returned' ) ); continue; } $position++; } // Assign to footer menu location $theme = get_option( 'contai_wordpress_theme', 'astra' ); // Theme-specific footer location overrides. // // Verified against each theme's register_nav_menus() call (#48). Three // themes are ABSENT on purpose: generatepress 3.6.1 registers only // 'primary' (functions.php:56-60), sydney 2.69 only 'primary'/'mobile' // (functions.php:56-65) and colormag 4.2.1 only 'primary'/'menu-secondary' // (inc/core/class-colormag-after-setup-theme.php:315-320). None of them has // a footer menu location in the free build, so inventing a plausible slug // here would recreate exactly the silent no-op this issue is about. With no // map entry the pattern fallback runs and, failing that, logs the // diagnostic naming the locations the theme really has. $theme_footer_map = array( 'astra' => 'footer_menu', 'neve' => 'footer', 'oceanwp' => 'footer_menu', 'blocksy' => 'footer', // Kadence's constant is FOOTER_NAV_MENU_SLUG = 'footer' // (kadence 1.5.1: inc/components/nav_menus/component.php:35,86); // 'footer_navigation' appears nowhere in the theme. 'kadence' => 'footer', // Newsmatic's footer nav is 'menu-3', guarded by has_nav_menu('menu-3') // (newsmatic 1.5.0: inc/hooks/footer-hooks.php:68-71), so the dead // 'footer-menu' entry rendered no footer navigation whatsoever. 'newsmatic' => 'menu-3', ); $target = $theme_footer_map[ $theme ] ?? null; $registered = get_registered_nav_menus(); // The wizard reaches this function in the same request as switch_theme(), // so the registry above can still describe the theme we just left. Judging // the incoming theme's location against it is wrong in both directions — // see contai_nav_registry_is_stale() (#48). $stale = contai_nav_registry_is_stale(); // Only short-circuit on the static map when the active theme actually // registers that location. Assigning an unregistered location is silently // dropped by WordPress, and returning here used to make the pattern-match // fallback and the diagnostic warning below unreachable for every mapped // theme — the silent failure behind "footer has no legal links" (#48). if ( contai_nav_location_is_usable( $target, $registered, $stale ) ) { // Claims the assignment as well, so core's post-switch remapping cannot // silently hand the location back to the previous theme (#48) — see // contai_assign_nav_menu_location(). contai_assign_nav_menu_location( $target, $menu_id ); // Binding the location is only half the job on a builder-driven theme: // the theme renders the menu from a component that has to be PLACED in // its footer layout, or nothing appears (#48). contai_theme_ensure_footer_menu_renders( $theme, $target ); return; } // Fallback: pattern-match footer location from registered nav menus. $matched = contai_match_footer_nav_location( $registered, $stale ); if ( null !== $matched ) { contai_assign_nav_menu_location( $matched, $menu_id ); contai_theme_ensure_footer_menu_renders( $theme, $matched ); error_log( "[ContAI] Footer menu assigned to '{$matched}' via pattern match for theme '{$theme}'" ); return; } // Durable, not debug-gated. The links themselves are no longer lost — three // of the nine mapped themes register no footer location at all, and // contai_render_footer_legal_fallback() renders the menu for them on // wp_footer — but "this theme exposes nowhere to bind a footer menu" is // still a fact about the site the owner has to be able to read (#48). contai_record_site_warning( 'footer nav location', sprintf( "no footer location found for theme '%s'%s. Registered menus: %s", $theme, $stale ? ' (registry still described the previous theme)' : '', implode( ', ', array_keys( is_array( $registered ) ? $registered : array() ) ) ) ); } /** * Fetch a generated profile from the 1Platform API. * * Calls POST /users/generations/profile and caches the result * in a WordPress transient keyed by owner + context + lang. * * @param string $owner Site owner name (sanitized before sending). * @param string $context Website niche/theme (sanitized before sending). * @param string $lang Language code: 'en' or 'es'. * @param int $ttl Cache TTL in seconds (default 6 hours). * @return array|null Profile data array on success, null on failure. * * Curl example for Postman import: * curl -X POST "https://api.1platform.pro/api/v1/users/generations/profile" \ * -H "Content-Type: application/json" \ * -H "Authorization: Bearer " \ * -H "x-user-token: " \ * -d '{"owner":"John Doe","context":"technology blog","lang":"en"}' * * Expected response: * { * "success": true, * "data": { * "fullname": "John Doe", * "gender": "male", * "bio": "

I'm John Doe, a technology enthusiast...

", * "rrss": "", * "profile_image_url": "https://example.com/images/profile.jpg" * } * } */ function contai_fetch_generated_profile_from_api( string $owner, string $context, string $lang, int $ttl = 21600 ): ?array { $safe_owner = sanitize_text_field( $owner ); $safe_context = sanitize_text_field( $context ); $safe_lang = sanitize_text_field( $lang ); $cache_key = 'contai_profile_' . md5( $safe_owner . $safe_context . $safe_lang ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) && ! empty( $cached ) ) { return $cached; } $client = ContaiOnePlatformClient::create(); $response = $client->post( ContaiOnePlatformEndpoints::GENERATIONS_PROFILE, array( 'owner' => $safe_owner, 'context' => $safe_context, 'lang' => $safe_lang, ) ); if ( ! $response->isSuccess() ) { contai_log( 'contai_fetch_generated_profile_from_api: API error — ' . ( $response->getMessage() ?? 'unknown' ) ); return null; } $data = $response->getData(); $required_fields = array( 'fullname', 'gender', 'bio', 'rrss', 'profile_image_url' ); foreach ( $required_fields as $field ) { if ( ! isset( $data[ $field ] ) || $data[ $field ] === '' ) { contai_log( 'contai_fetch_generated_profile_from_api: Missing field — ' . $field ); return null; } } set_transient( $cache_key, $data, $ttl ); return $data; } /** * Download a profile image and upload it to the WordPress media library. * * Avoids duplicate uploads by checking for an existing attachment with matching * _contai_source_image_url post meta. * * @param string $profile_image_url Remote image URL to download. * @param string $fullname Person's full name (used for filename). * @return string|false Local attachment URL on success, false on failure. */ function contai_sideload_profile_image( string $profile_image_url, string $fullname ) { if ( empty( $profile_image_url ) || ! filter_var( $profile_image_url, FILTER_VALIDATE_URL ) ) { return false; } // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query needed to find existing attachment by source URL. $existing = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'meta_key' => '_contai_source_image_url', 'meta_value' => $profile_image_url, 'numberposts' => 1, 'fields' => 'ids', ) ); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value if ( ! empty( $existing ) ) { $url = wp_get_attachment_url( $existing[0] ); if ( $url ) { return $url; } } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $tmp = download_url( $profile_image_url ); if ( is_wp_error( $tmp ) ) { contai_log( 'contai_sideload_profile_image: download failed — ' . $tmp->get_error_message() ); return false; } $file_array = array( 'name' => sanitize_file_name( $fullname . '-profile.jpg' ), 'tmp_name' => $tmp, ); $attachment_id = media_handle_sideload( $file_array, 0 ); if ( is_wp_error( $attachment_id ) ) { wp_delete_file( $tmp ); contai_log( 'contai_sideload_profile_image: sideload failed — ' . $attachment_id->get_error_message() ); return false; } update_post_meta( $attachment_id, '_contai_source_image_url', $profile_image_url ); return wp_get_attachment_url( $attachment_id ); } /** * Add default widgets to sidebar * * Generates and configures default widgets for the primary sidebar including: * - About Me profile card (via WPContentAI API, with legacy fallback) * - Search widget for site-wide search * - Recent Comments widget showing latest comments * - Recent Posts widget showing latest posts with dates * * The About Me card first attempts the WPContentAI profile generation API. * If the API call fails (network error, invalid response, missing data), * it falls back to the legacy flow (OpenAI + ValueSerp + Pexels). * * All widget titles are automatically translated based on site language setting. * * @return void */ function contai_add_sidebar_widgets() { $lang = get_option( 'contai_site_language', 'spanish' ); $theme = get_option( 'contai_site_topic', get_option( 'contai_site_theme', 'blog' ) ); // Clear cached profile so re-execution fetches a fresh one (#55) $legal_info_pre = contai_get_legal_info(); $owner_pre = sanitize_text_field( $legal_info_pre['owner'] ?? '' ); $lang_code_pre = ( $lang === 'english' ? 'en' : 'es' ); $cache_key = 'contai_profile_' . md5( $owner_pre . sanitize_text_field( $theme ) . sanitize_text_field( $lang_code_pre ) ); delete_transient( $cache_key ); $labels = array( 'spanish' => array( 'search' => 'Búsqueda', 'recent_comments' => 'Últimos comentarios', 'recent_posts' => 'Últimas entradas', ), 'english' => array( 'search' => 'Search', 'recent_comments' => 'Recent Comments', 'recent_posts' => 'Recent Posts', ), ); $text = $labels[ $lang ] ?? $labels['spanish']; $sidebars_widgets = get_option( 'sidebars_widgets', array() ); $sidebar_id = contai_get_primary_sidebar_id(); // Read the ids this plugin assigned on a previous run BEFORE clearing the // list, so the allocation below can re-use them (#48). $previous_sidebar = isset( $sidebars_widgets[ $sidebar_id ] ) && is_array( $sidebars_widgets[ $sidebar_id ] ) ? $sidebars_widgets[ $sidebar_id ] : array(); // Collected here and merged with $previous_sidebar at the end. Assigning // array() to the sidebar at this point — which is what this did — dropped // every widget the site owner had placed there (#48). $wizard_widget_ids = array(); // Read-merge, never rebuild. Each of these options holds EVERY instance of // that widget type across EVERY sidebar, so overwriting it with a freshly // built one-instance array silently destroyed the settings of widgets other // sidebars still reference (#48). $widget_search = get_option( 'widget_search', array() ); $widget_recent_comments = get_option( 'widget_recent-comments', array() ); $widget_recent_posts = get_option( 'widget_recent-posts', array() ); $widget_block = get_option( 'widget_block', array() ); $widget_search = is_array( $widget_search ) ? $widget_search : array(); $widget_recent_comments = is_array( $widget_recent_comments ) ? $widget_recent_comments : array(); $widget_recent_posts = is_array( $widget_recent_posts ) ? $widget_recent_posts : array(); $widget_block = is_array( $widget_block ) ? $widget_block : array(); // Hardcoding 1 collided with whatever already occupied instance 1. The // fingerprints are what keep re-use from adopting a widget the site owner // (or WordPress itself) put in this sidebar — 'block-2' on a stock install // is core's Search block, not ours (#48). $search_id = contai_pick_widget_instance_id( $widget_search, $previous_sidebar, 'search', $text['search'] ); $comments_id = contai_pick_widget_instance_id( $widget_recent_comments, $previous_sidebar, 'recent-comments', $text['recent_comments'] ); $posts_id = contai_pick_widget_instance_id( $widget_recent_posts, $previous_sidebar, 'recent-posts', $text['recent_posts'] ); $block_id = contai_pick_widget_instance_id( $widget_block, $previous_sidebar, 'block', CONTAI_ABOUT_ME_WIDGET_CLASS ); $legal_info = contai_get_legal_info(); $owner = $legal_info['owner'] ?? ''; $website_provider = new ContaiWebsiteProvider(); $lang_code = $website_provider->getLanguageCode() ?? ( $lang === 'english' ? 'en' : 'es' ); $profile = contai_fetch_generated_profile_from_api( $owner, $theme, $lang_code ); if ( $profile ) { $fullname = sanitize_text_field( $profile['fullname'] ); $bio_safe = wp_kses_post( $profile['bio'] ); $rrss_safe = wp_kses_post( $profile['rrss'] ); $image_url = contai_sideload_profile_image( $profile['profile_image_url'], $fullname ); if ( ! $image_url ) { $image_url = esc_url( $profile['profile_image_url'] ); } $about_me_title = $lang === 'english' ? 'About Me' : 'Sobre mí'; $about_me_html = '
' . esc_attr( $fullname ) . '

' . esc_html( $about_me_title ) . '

' . $bio_safe . '

' . $rrss_safe . '
'; $wizard_widget_ids[] = "block-$block_id"; $widget_block[ $block_id ] = array( 'content' => $about_me_html ); } else { // Durable, not debug-gated: contai_log() writes nothing without // WP_DEBUG, and the step still reports "Widgets generated", so one of // the four widgets went missing with no trace anywhere (#48). contai_record_site_warning( 'about me widget', 'profile generation returned nothing; the About Me widget was skipped.' ); } $wizard_widget_ids[] = "search-$search_id"; $wizard_widget_ids[] = "recent-comments-$comments_id"; $wizard_widget_ids[] = "recent-posts-$posts_id"; // Wizard widgets first, everything the owner already had after it. Re-runs // collapse instead of duplicating because contai_pick_widget_instance_id() // hands back the ids the previous run wrote. $sidebars_widgets[ $sidebar_id ] = contai_merge_sidebar_widget_ids( $wizard_widget_ids, $previous_sidebar ); $widget_search[ $search_id ] = array( 'title' => $text['search'], ); $widget_recent_comments[ $comments_id ] = array( 'title' => $text['recent_comments'], 'number' => 5, ); $widget_recent_posts[ $posts_id ] = array( 'title' => $text['recent_posts'], 'number' => 5, 'show_date' => true, ); $widget_search['_multiwidget'] = 1; $widget_recent_comments['_multiwidget'] = 1; $widget_recent_posts['_multiwidget'] = 1; $widget_block['_multiwidget'] = 1; // These are standard WordPress core widget option names (widget_block, widget_search, etc.). // WordPress requires these exact names to configure sidebar widgets programmatically. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedKeyFound -- Core WordPress widget option names. update_option( 'widget_block', $widget_block ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedKeyFound -- Core WordPress widget option name. update_option( 'widget_search', $widget_search ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedKeyFound -- Core WordPress widget option name. update_option( 'widget_recent-comments', $widget_recent_comments ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedKeyFound -- Core WordPress widget option name. update_option( 'widget_recent-posts', $widget_recent_posts ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedKeyFound -- Core WordPress widget option name. update_option( 'sidebars_widgets', $sidebars_widgets ); }